File size: 16,619 Bytes
aa89cb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
import type { MutationQuery, SelectQuery } from './ast';
import { cloneTraceNodes, queryTraceSource, TraceNode, MutationTraceScope, mutationScopeForEntity } from './trace-chain';
import type { EntityKey, EntityRoot } from './entity-root';
import { inheritSQLLogBindings, logValueStrings, privateLogValueStrings, scrubLogText } from './log-privacy';
import type { SQLLogBindingSource } from './log-privacy';
import { snapshotQuery, queryExecutionOwner, retainQueryExecutionOwner } from './query-snapshot';
import { LoadedScalarSnapshot } from './loaded-scalar-snapshot';

const mutationSnapshots = new WeakMap<object, LoadedScalarSnapshot>();

// Only runtime-created snapshots carry provenance. No property supplied by a
// JSON/builder caller can forge it, and no mutable trace stack lives on Context.
const querySources = new WeakMap<object, readonly TraceNode[]>();
const graphRequests = new WeakMap<object, { session: GraphMutationSession;
  parent?: MutationTraceScope; localComment?: string }>();
const scopeOwners = new WeakMap<MutationTraceScope, GraphMutationSession>();

export type QueryOperation = Readonly<{
  kind: 'named-count' | 'facet' | 'relation-count' | 'relation-select' | 'aggregate' | 'pagination';
  /** Exact generator-supplied native method; absent for a low-level runtime operation. */
  method?: string;
  alias?: string;
  relation?: string;
}>;

function operationSnapshot(operation: QueryOperation): QueryOperation {
  if (!['named-count', 'facet', 'relation-count', 'relation-select', 'aggregate', 'pagination'].includes(operation.kind)
    || (operation.method !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(operation.method))
    || (operation.alias !== undefined && typeof operation.alias !== 'string')
    || (operation.relation !== undefined && typeof operation.relation !== 'string')) {
    throw new Error('QUERY_OPERATION_INVALID: invalid generated operation descriptor');
  }
  return Object.freeze({ kind: operation.kind, method: operation.method,
    alias: operation.alias, relation: operation.relation });
}

/** One native literal renderer for all count/facet aliases. Never parse SQL. */
export function queryOperationComment(operation: QueryOperation): string {
  const captured = operationSnapshot(operation);
  const literal = (value: string) => "'" + value.replace(/\\/g, '\\\\')
    .replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r')
    .replace(/\t/g, '\\t').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') + "'";
  if (!captured.method) return 'framework: ' + captured.kind
    + (captured.relation ? ' ' + captured.relation : '')
    + (captured.alias !== undefined ? ' ' + literal(captured.alias) : '');
  return captured.method + '(' + (captured.alias !== undefined ? literal(captured.alias)
    : captured.kind === 'relation-select' ? '...' : '') + ')';
}

const requestBindingToken = Symbol('runtime request binding');
type QueryBinding = {
  [requestBindingToken]: true;
  parent?: QueryRequest<any>;
  comment: string;
  name: string;
  operation?: QueryOperation;
  source: readonly TraceNode[];
  invocation?: object;
};

export type RequestKind = 'query' | 'mutation';

/** Stable, value-free request-boundary diagnostics. */
export class RequestIntentError extends Error {
  constructor(
    public readonly code: 'REQUEST_COMMENT_REQUIRED' | 'QUERY_PURPOSE_REQUIRED',
    public readonly field: 'comment' | 'purpose',
    public readonly requestKind: RequestKind,
  ) {
    super(`${code}: ${requestKind} request requires a non-blank ${field}; supply it at the request entry point`);
    this.name = 'RequestIntentError';
  }
}

// Match Rust str::trim (Unicode White_Space), not JavaScript trim: NEL is
// whitespace, while BOM is not. Validate but never trim the supplied text.
function requireText(value: unknown, field: 'comment' | 'purpose', kind: RequestKind): string {
  if (typeof value !== 'string' || /^\p{White_Space}*$/u.test(value)) {
    throw new RequestIntentError(
      field === 'comment' ? 'REQUEST_COMMENT_REQUIRED' : 'QUERY_PURPOSE_REQUIRED', field, kind,
    );
  }
  return value;
}

export class QueryIntent {
  readonly #comment: string;
  readonly #purpose: string;

  constructor(comment: unknown, purpose: unknown) {
    this.#comment = requireText(comment, 'comment', 'query');
    this.#purpose = requireText(purpose, 'purpose', 'query');
    Object.freeze(this);
  }

  get comment(): string { return this.#comment; }
  get purpose(): string { return this.#purpose; }
}

export class MutationIntent {
  readonly #comment: string;

  constructor(comment: unknown) {
    this.#comment = requireText(comment, 'comment', 'mutation');
    Object.freeze(this);
  }

  get comment(): string { return this.#comment; }
  get auditReason(): string { return this.#comment; }
  readbackIntent(): QueryIntent {
    return new QueryIntent(this.#comment, 'verify persisted mutation result');
  }
}

/**
 * Request intent is owned separately from the mutable query builder. A snapshot
 * retains local, non-enumerable pagination capabilities without serializing them.
 */
export class QueryRequest<T extends object = SelectQuery> {
  readonly #intent: QueryIntent;
  readonly #query: T;
  readonly #parent?: QueryRequest<any>;
  readonly #comment: string;
  readonly #name: string;
  readonly #operation?: QueryOperation;
  readonly #invocation: object;

  constructor(query: T, intent?: QueryIntent, binding?: QueryBinding) {
    const source = query as any;
    const previous = queryExecutionOwner(query);
    const previousOwner = previous instanceof QueryRequest ? previous : undefined;
    this.#intent = intent === undefined
      ? previousOwner?.intent
        ?? new QueryIntent(source?._comment ?? source?.commentText, source?._purpose ?? source?.purposeText)
      : new QueryIntent(intent?.comment, intent?.purpose);
    if (binding && binding[requestBindingToken] !== true)
      throw new Error('QUERY_REQUEST_OWNERSHIP_INVALID: request binding is runtime-owned');
    const owner = previousOwner
      && previousOwner.intent.comment === this.#intent.comment
      && previousOwner.purpose === this.#intent.purpose ? previousOwner : undefined;
    this.#invocation = binding?.invocation ?? (binding?.parent ? binding.parent.#invocation : undefined)
      ?? (owner ? owner.#invocation : undefined) ?? Object.freeze({});
    this.#parent = binding ? binding.parent : owner?.parentRequest;
    this.#comment = requireText(binding?.comment ?? owner?.comment ?? this.#intent.comment, 'comment', 'query');
    this.#name = binding?.name ?? owner?.requestName ?? String(source.entity ?? 'query');
    this.#operation = binding?.operation ?? owner?.operation;
    this.#query = snapshotQuery(query);
    const captured = this.#query as any;
    // Every request owns its local comment. Purpose is resolved from the root
    // request through parent ownership and is copied only onto the private SQL
    // execution snapshot for boundary validation.
    for (const [field, value] of [
      ['commentText', this.comment], ['purposeText', this.purpose],
      ['_comment', this.comment], ['_purpose', this.purpose],
    ] as const) {
      Object.defineProperty(captured, field, { value, enumerable: !field.startsWith('_'),
        configurable: true, writable: false });
    }
    querySources.set(captured, cloneTraceNodes(binding?.source ?? owner?.traceSource ?? querySources.get(query)
      ?? queryTraceSource(String(source.entity), this.intent.comment, this.purpose)));
    retainQueryExecutionOwner(captured, this);
    Object.freeze(this);
  }

  get intent(): QueryIntent { return this.#intent; }
  get query(): T { return this.#query; }
  get comment(): string { return this.#comment; }
  get purpose(): string { return this.#intent.purpose; }
  get traceSource(): readonly TraceNode[] { return querySources.get(this.#query)!; }
  get parentRequest(): QueryRequest<any> | undefined { return this.#parent; }
  get rootRequest(): QueryRequest<any> {
    let root: QueryRequest<any> = this;
    while (root.parentRequest) root = root.parentRequest;
    return root;
  }
  get requestName(): string { return this.#name; }
  get operation(): QueryOperation | undefined { return this.#operation; }
  get traceChain(): readonly TraceNode[] {
    const comments: TraceNode[] = [];
    for (let node: QueryRequest<any> | undefined = this; node; node = node.parentRequest)
      comments.push({ kind: 'comment', name: node.requestName, detail: node.comment });
    return cloneTraceNodes([{ kind: 'purpose', name: this.rootRequest.requestName, detail: this.purpose },
      ...comments.reverse()]);
  }

  /** Runtime derivation preserves this invocation's source across builder clones. */
  withQuery<Q extends object>(query: Q): QueryRequest<Q> {
    const previous = queryExecutionOwner(query);
    if (previous instanceof QueryRequest && previous.#invocation !== this.#invocation) {
      throw new Error('QUERY_SCOPE_OWNER_MISMATCH: replacement belongs to another query invocation');
    }
    return new QueryRequest(query, this.intent, { [requestBindingToken]: true,
      parent: this.parentRequest, comment: this.comment, name: this.requestName,
      operation: this.operation, source: this.traceSource, invocation: this.#invocation });
  }

  /** A real operation child need not execute SQL itself; descendants retain it. */
  deriveOperation<Q extends object>(query: Q, operation: QueryOperation): QueryRequest<Q> {
    const captured = operationSnapshot(operation);
    return new QueryRequest(query, this.intent, { [requestBindingToken]: true,
      parent: this, comment: queryOperationComment(captured), name: String((query as any).entity ?? this.requestName),
      operation: captured, source: this.traceSource });
  }

  /** Local description for work created by the framework, never a root authorization. */
  deriveHelper<Q extends object>(query: Q, comment: string): QueryRequest<Q> {
    return new QueryRequest(query, this.intent, { [requestBindingToken]: true,
      parent: this, comment: requireText(comment, 'comment', 'query'),
      name: String((query as any).entity ?? this.requestName), source: this.traceSource });
  }

  /** Preserve the Q-operation parent and the actual nested request's own comment. */
  derive<Q extends object>(query: Q, relation: string, operation?: QueryOperation): QueryRequest<Q> {
    if (operation) return this.deriveOperation(this.query, operation).deriveChild(query, relation);
    const owner = `${String((this.query as any).entity)}.${relation}`;
    return this.deriveChild(query, relation, undefined, owner);
  }

  /** Aggregates execute their operation; an anonymous template is not a value load. */
  deriveAggregate<Q extends object>(query: Q, relation: string, operation: QueryOperation): QueryRequest<Q> {
    const facet = this.parentRequest?.operation?.kind === 'facet' ? this.parentRequest : undefined;
    const branch = facet
      ? facet.deriveHelper(query, 'aggregate ' + facet.operation!.relation)
      : this.deriveOperation(query, operation);
    const physical = [...this.traceSource, { kind: 'relation' as const, name: relation,
      detail: `${String((this.query as any).entity)}.${relation}` }];
    const scoped = new QueryRequest(query, this.intent, { [requestBindingToken]: true,
      parent: branch.parentRequest, comment: branch.comment, name: branch.requestName,
      operation: branch.operation, source: physical });
    const comment = (query as any)._comment ?? (query as any).commentText;
    return comment === undefined ? scoped : scoped.deriveChild(query);
  }

  /** Bind a framework helper or value query below an existing operation parent. */
  deriveChild<Q extends object>(
    query: Q,
    relation?: string,
    defaultComment?: string,
    requestName?: string,
  ): QueryRequest<Q> {
    const source = query as any;
    const comment = source._comment ?? source.commentText;
    return new QueryRequest(query, this.intent, { [requestBindingToken]: true,
      parent: this, comment: comment === undefined
        ? (defaultComment ?? `load ${String(source.entity)}`)
        : requireText(comment, 'comment', 'query'),
      name: requestName ?? String(source.entity), source: relation === undefined ? this.traceSource : [...this.traceSource, {
      kind: 'relation', name: relation, detail: `${String((this.query as any).entity)}.${relation}`,
    }] });
  }
}

/** A batch also needs this root envelope; child comments are not a fallback. */
export class MutationRequest<T extends object = MutationQuery> {
  readonly #intent: MutationIntent;
  readonly #mutation: T;

  constructor(mutation: T, intent?: MutationIntent) {
    this.#intent = new MutationIntent(intent === undefined ? (mutation as any)?.comment : intent?.comment);
    this.#mutation = { ...mutation };
    Object.defineProperty(this.#mutation, 'comment', {
      value: this.#intent.comment, enumerable: true, writable: false, configurable: false,
    });
    Object.freeze(this);
  }

  get intent(): MutationIntent { return this.#intent; }
  get mutation(): T { return this.#mutation; }
  get comment(): string { return this.#intent.comment; }

  /** @internal Generated hydration/commit provenance, never a wire field. */
  withLoadedSnapshot(snapshot: LoadedScalarSnapshot): this {
    mutationSnapshots.set(this, new LoadedScalarSnapshot(snapshot.values()));
    return this;
  }
  /** @internal Does not become part of the write payload or policy input. */
  loadedValues(): Readonly<Record<string, unknown>> { return mutationSnapshots.get(this)?.values() ?? {}; }

  /** Runtime-owned execution capability; raw mutation fields cannot forge it. */
  get graphSession(): GraphMutationSession | undefined { return graphRequests.get(this)?.session; }

  scopeFor(key: EntityKey): MutationTraceScope {
    const graph = graphRequests.get(this);
    const scope = mutationScopeForEntity(graph?.parent, key.entity, key.id, this.comment, graph?.localComment);
    if (graph) scopeOwners.set(scope, graph.session);
    return scope;
  }

  traceFor(key: EntityKey): readonly TraceNode[] {
    const mutation = this.#mutation as T & { ledgerRoot?: EntityRoot; ledgerKey?: EntityKey };
    const specific = mutation.ledgerRoot?.traceChain(mutation.ledgerKey ?? key);
    return specific?.length ? cloneTraceNodes(specific) : this.scopeFor(key).recover();
  }

  /** Safe event projection; internal policy intent is never mutated. */
  auditProjection(key: EntityKey, payload: unknown, bindings?: SQLLogBindingSource): Readonly<{
    reason: string; mutationLineage: readonly TraceNode[];
  }> {
    const secrets = [...logValueStrings(payload), ...logValueStrings((this.#mutation as any).id),
      ...(bindings ? privateLogValueStrings(bindings) : logValueStrings(this.loadedValues())),
      ...privateLogValueStrings(this.graphSession?.logBindings)];
    return Object.freeze({ reason: scrubLogText(this.comment, secrets)!,
      mutationLineage: cloneTraceNodes(this.traceFor(key).map(node => ({ ...node,
        detail: scrubLogText(node.detail, secrets) }))) });
  }
}

/** One explicit graph invocation, never a Context-owned trace stack. */
export class GraphMutationSession {
  readonly #intent: MutationIntent;
  #bindings?: SQLLogBindingSource;

  constructor(intent: MutationIntent) {
    this.#intent = new MutationIntent(intent?.comment);
    Object.freeze(this);
  }
  get intent(): MutationIntent { return this.#intent; }

  request<T extends object>(mutation: T, parent?: MutationTraceScope,
    localComment?: string): MutationRequest<T> {
    if (parent && scopeOwners.get(parent) !== this)
      throw new Error('GRAPH_TRACE_SCOPE_MISMATCH: parent scope belongs to another graph invocation');
    const request = new MutationRequest(mutation, this.#intent);
    graphRequests.set(request, { session: this, parent, localComment });
    return request;
  }

  /** @internal Preflight snapshots bind provenance for all siblings before SQL. */
  captureLogBindings(source: SQLLogBindingSource): void {
    this.#bindings = inheritSQLLogBindings(source, this.#bindings);
  }
  /** @internal Never put this raw provenance on a wire or log record. */
  get logBindings(): SQLLogBindingSource | undefined { return this.#bindings; }
}

/** Database committed; retrying this operation as a rolled-back write is unsafe. */
export class GraphCommittedError extends Error {
  readonly committed = true;
  constructor(public readonly cause: unknown) {
    super('GRAPH_ALREADY_COMMITTED: post-commit processing failed; do not retry as an uncommitted mutation');
    this.name = 'GraphCommittedError';
  }
}