91 lines
7.7 KiB
TypeScript
91 lines
7.7 KiB
TypeScript
// Private typed VALUE framing only. No SQL/pool/role/current-schema attestation.
|
|
// Transport limits are inherited unchanged from the accepted paged DATA codec;
|
|
// they DO NOT expand the legacy2048 total-constraint policy.
|
|
import {createHash,type Hash} from 'node:crypto'
|
|
import {canonical as writerCanonical} from './application-writer-profile-canonical.js'
|
|
|
|
export type Json = null|boolean|number|string|Json[]|{[key:string]:Json}
|
|
export type CatalogueKind='global'|'base'|'leaf'
|
|
export interface CatalogueRecord {kind:CatalogueKind;identity:string;value:{[key:string]:Json}}
|
|
export const FRAME_LIMITS=Object.freeze({chunkBytes:4096,pageRows:128,recordBytes:2*1024*1024,streamBytes:64*1024*1024,identityBytes:1024,depth:64})
|
|
export interface StreamMetrics {records:number;chunks:number;pages:number;payloadBytes:number;estimatedDataRowBytes:number;maxPagePayloadBytes:number;maxRecordBytes:number}
|
|
export interface CatalogueValueDigest {format:'catalogue-values-v2';baseSha256:string;leafSha256:string;snapshotSha256:string;metrics:Readonly<StreamMetrics>;authority:false}
|
|
const rank:Record<CatalogueKind,number>={global:0,base:1,leaf:2}
|
|
const domain=(name:string)=>createHash('sha256').update('mmapp:catalogue-'+name+':v2\0')
|
|
function object(value:unknown):value is Record<string,unknown>{return !!value&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype}
|
|
function exactJson(value:Json,depth=0):void {
|
|
if(depth>FRAME_LIMITS.depth)throw Error('Catalogue nesting bound')
|
|
if(value===null||typeof value==='boolean'||typeof value==='string')return
|
|
if(typeof value==='number'){if(!Number.isSafeInteger(value)||Object.is(value,-0))throw Error('Exact integer/string catalogue scalar required');return}
|
|
if(Array.isArray(value)){for(const item of value)exactJson(item,depth+1);return}
|
|
if(!object(value))throw Error('Plain catalogue JSON required')
|
|
for(const key of Object.keys(value))exactJson(value[key] as Json,depth+1)
|
|
}
|
|
function canonical(value:Json):string{exactJson(value);return writerCanonical(value)}
|
|
function update(hash:Hash,encoded:Buffer){const length=Buffer.alloc(4);length.writeUInt32BE(encoded.length);hash.update(length);hash.update(encoded)}
|
|
function record(bytes:Buffer,kind:CatalogueKind,identity:string):CatalogueRecord{
|
|
const parsed:unknown=JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes))
|
|
if(!object(parsed)||Object.keys(parsed).sort().join(',')!=='identity,kind,value'||parsed.kind!==kind||parsed.identity!==identity||!object(parsed.value))throw Error('Exact catalogue record shape required')
|
|
// canonical() also rejects numbers/prototypes outside the scalar contract.
|
|
canonical(parsed as unknown as Json)
|
|
return parsed as unknown as CatalogueRecord
|
|
}
|
|
export class CatalogueValueStream {
|
|
private readonly base=domain('base-values');private readonly leaf=domain('leaf-values');private readonly snapshot=domain('snapshot-values')
|
|
private readonly metrics:StreamMetrics={records:0,chunks:0,pages:0,payloadBytes:0,estimatedDataRowBytes:0,maxPagePayloadBytes:0,maxRecordBytes:0}
|
|
private next=1n;private parts:Buffer[]=[];private bytes=0;private expectedParts=0;private currentKind?:CatalogueKind;private currentIdentity=''
|
|
private previous?:{kind:CatalogueKind;identity:string};private finished=false;private accepting=false;private failed=false;private baseRecords=0;private leafRecords=0;
|
|
*acceptPage(rows:readonly unknown[]):Generator<CatalogueRecord>{
|
|
if(this.finished||this.accepting||this.failed||!Array.isArray(rows)||rows.length>FRAME_LIMITS.pageRows){this.failed=true;throw Error('Closed/oversized catalogue page')}
|
|
this.accepting=true;let complete=false
|
|
try{
|
|
this.metrics.pages++;let pageBytes=0
|
|
for(const raw of rows){
|
|
if(!object(raw)||Object.keys(raw).sort().join(',')!=='chunk_count,chunk_index,identity,kind,payload,record_id')throw Error('Exact cursor row shape required')
|
|
const {record_id:id,chunk_index:part,chunk_count:count,kind,identity,payload}=raw
|
|
if(typeof id!=='string'||!/^[1-9][0-9]{0,18}$/.test(id)||BigInt(id)!==this.next||typeof kind!=='string'||!Object.hasOwn(rank,kind)||typeof identity!=='string'||Buffer.byteLength(identity)<1||Buffer.byteLength(identity)>FRAME_LIMITS.identityBytes||!Number.isInteger(part)||!Number.isInteger(count)||typeof part!=='number'||typeof count!=='number'||count<1||count>FRAME_LIMITS.recordBytes/FRAME_LIMITS.chunkBytes||part!==this.parts.length||part>=count||!Buffer.isBuffer(payload)||payload.length<1||payload.length>FRAME_LIMITS.chunkBytes||part<count-1&&payload.length!==FRAME_LIMITS.chunkBytes)throw Error('Catalogue order/chunk continuity refused')
|
|
const k=kind as CatalogueKind
|
|
if(part===0){if(this.previous&&(rank[k]<rank[this.previous.kind]||rank[k]===rank[this.previous.kind]&&Buffer.compare(Buffer.from(identity),Buffer.from(this.previous.identity))<=0))throw Error('Strict catalogue identity order required');this.currentKind=k;this.currentIdentity=identity;this.expectedParts=count}
|
|
if(k!==this.currentKind||identity!==this.currentIdentity||count!==this.expectedParts)throw Error('Changed record framing refused')
|
|
this.parts.push(Buffer.from(payload));this.bytes+=payload.length;pageBytes+=payload.length;this.metrics.chunks++;this.metrics.payloadBytes+=payload.length
|
|
// Text protocol bytea uses hex. DataRow header + six field lengths + actual
|
|
// other text field sizes; RowDescription/ReadyForQuery/JS heap are separate.
|
|
this.metrics.estimatedDataRowBytes+=31+Buffer.byteLength(id)+String(part).length+String(count).length+Buffer.byteLength(k)+Buffer.byteLength(identity)+2+2*payload.length
|
|
if(this.bytes>FRAME_LIMITS.recordBytes||pageBytes>FRAME_LIMITS.pageRows*FRAME_LIMITS.chunkBytes||this.metrics.payloadBytes>FRAME_LIMITS.streamBytes)throw Error('Catalogue byte budget exceeded')
|
|
if(part===count-1){
|
|
const value=record(Buffer.concat(this.parts,this.bytes),k,identity),encoded=Buffer.from(canonical(value as unknown as Json))
|
|
if(encoded.length>FRAME_LIMITS.recordBytes)throw Error('Canonical catalogue record overflow')
|
|
const dynamic=k==='leaf'||k==='global'&&identity==='source_size'
|
|
update(this.snapshot,encoded);update(dynamic?this.leaf:this.base,encoded)
|
|
if(dynamic)this.leafRecords++;else this.baseRecords++
|
|
this.metrics.records++;this.metrics.maxRecordBytes=Math.max(this.metrics.maxRecordBytes,this.bytes);this.previous={kind:k,identity};this.next++;this.parts=[];this.bytes=0;this.expectedParts=0
|
|
yield value
|
|
}
|
|
}
|
|
this.metrics.maxPagePayloadBytes=Math.max(this.metrics.maxPagePayloadBytes,pageBytes)
|
|
complete=true
|
|
}finally{this.accepting=false;if(!complete)this.failed=true}
|
|
}
|
|
finish():CatalogueValueDigest{
|
|
if(this.finished||this.accepting||this.failed||this.parts.length)throw Error('Catalogue stream incomplete/closed')
|
|
this.finished=true
|
|
// Each digest includes its own record count, so empty/partitioned streams
|
|
// cannot acquire an ambiguous concatenation encoding.
|
|
const digest=(hash:Hash,count:number)=>{const end=Buffer.alloc(8);end.writeBigUInt64BE(BigInt(count));return hash.update(end).digest('hex')}
|
|
return Object.freeze({format:'catalogue-values-v2',baseSha256:digest(this.base,this.baseRecords),leafSha256:digest(this.leaf,this.leafRecords),snapshotSha256:digest(this.snapshot,this.metrics.records),metrics:Object.freeze({...this.metrics}),authority:false})
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Fail-closed DATA counter for the unchanged legacy total constraint ceiling.
|
|
* Paging is not permission to reset/reinterpret that total on every page. */
|
|
export class FixedCatalogueConstraintRowBudgetData {
|
|
private count=0;private failed=false
|
|
acceptRows(rows:readonly unknown[]):number {
|
|
if(this.failed||!Array.isArray(rows)){this.failed=true;throw Error('Closed/invalid constraint row budget')}
|
|
if(this.count+rows.length>2048){this.failed=true;throw Error('Legacy total constraint row cap2048 exceeded')}
|
|
this.count+=rows.length;return this.count
|
|
}
|
|
}
|