Files
MikrotikManager/backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts
T

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
}
}