graphddb 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,14 +22,17 @@ import {
22
22
  commitTransaction,
23
23
  compileRawCondition,
24
24
  ctxModelFor,
25
+ detectRelationFields,
25
26
  execItemKeySignature,
26
27
  executeDelete,
27
28
  executePut,
28
29
  executeTransaction,
29
30
  executeUpdate,
30
31
  getEntityWrites,
32
+ getImplicitKeyFields,
31
33
  hydrate,
32
34
  isEntityWritesDefinition,
35
+ isInlineSnapshotSpec,
33
36
  isLifecycleContract,
34
37
  isParam,
35
38
  isRawCondition,
@@ -44,8 +47,9 @@ import {
44
47
  resolveSegmentedKey,
45
48
  serializeFieldValue,
46
49
  serializeRawCondition,
47
- skTemplate
48
- } from "./chunk-CCIVET5K.js";
50
+ skTemplate,
51
+ validateInlineSnapshotSelect
52
+ } from "./chunk-F2DI3GTI.js";
49
53
  import {
50
54
  TableMapping,
51
55
  createColumnMap,
@@ -116,6 +120,10 @@ function buildRelations(metadata) {
116
120
  type: rel.type,
117
121
  target: targetClass.name,
118
122
  keyBinding: { ...rel.keyBinding },
123
+ // List-valued source descriptor for a `refs` relation (issue #197); emitted
124
+ // only for `refs` so a scalar-keyed relation is byte-identical to the pre-#197
125
+ // manifest.
126
+ ...rel.refs !== void 0 ? { refs: { ...rel.refs } } : {},
119
127
  // Purely-documentary relation description (issue #166); emitted only when
120
128
  // declared so an undescribed relation is byte-identical to the pre-#166 manifest.
121
129
  ...rel.options?.description !== void 0 ? { description: rel.options.description } : {}
@@ -190,36 +198,6 @@ function buildManifest(registry = MetadataRegistry) {
190
198
  };
191
199
  }
192
200
 
193
- // src/relation/detect.ts
194
- function isRelationSpec(value) {
195
- if (typeof value !== "object" || value === null) return false;
196
- return "select" in value || isSelectBuilder(value);
197
- }
198
- function detectRelationFields(select, metadata) {
199
- const result = [];
200
- for (const [fieldName, value] of Object.entries(select)) {
201
- if (isRelationSpec(value)) {
202
- const rel = metadata.relations.find((r) => r.propertyName === fieldName);
203
- if (rel) {
204
- result.push(rel);
205
- }
206
- }
207
- }
208
- return result;
209
- }
210
- function getImplicitKeyFields(select, metadata) {
211
- const relations = detectRelationFields(select, metadata);
212
- const needed = /* @__PURE__ */ new Set();
213
- for (const rel of relations) {
214
- for (const sourceField of Object.values(rel.keyBinding)) {
215
- if (!(sourceField in select) || select[sourceField] !== true) {
216
- needed.add(sourceField);
217
- }
218
- }
219
- }
220
- return [...needed];
221
- }
222
-
223
201
  // src/planner/projection.ts
224
202
  function buildProjection(select, additionalFields) {
225
203
  const paths = [];
@@ -237,6 +215,8 @@ function buildProjection(select, additionalFields) {
237
215
  for (const [fieldName, value] of Object.entries(selectObj)) {
238
216
  if (value === true) {
239
217
  addPath([...parentSegments, fieldName]);
218
+ } else if (isInlineSnapshotSpec(value)) {
219
+ addPath([...parentSegments, fieldName]);
240
220
  } else if (typeof value === "object" && value !== null) {
241
221
  if ("select" in value || isSelectBuilder(value)) {
242
222
  continue;
@@ -702,6 +682,11 @@ async function executeListInternal(modelClass, key, selectSpec, options = {}) {
702
682
  const after = options.after ?? normalized.after;
703
683
  const limit = options.limit ?? normalized.limit;
704
684
  const order = options.order ?? normalized.order;
685
+ validateInlineSnapshotSelect(
686
+ select,
687
+ metadata,
688
+ (rel) => MetadataRegistry.get(rel.targetFactory())
689
+ );
705
690
  const exclusiveStartKey = after ? decodeCursor(after) : void 0;
706
691
  const implicitKeys = getImplicitKeyFields(select, metadata);
707
692
  const executionPlan = plan(metadata, {
@@ -970,7 +955,7 @@ function relationResultPaths(select, metadata, parentPath = "$") {
970
955
  const childSelect = normalizeSelectSpec(select[rel.propertyName]).select ?? {};
971
956
  const targetMeta = MetadataRegistry.get(rel.targetFactory());
972
957
  const childPath = `${parentPath}.${rel.propertyName}`;
973
- const isMany = rel.type === "hasMany";
958
+ const isMany = rel.type === "hasMany" || rel.type === "refs";
974
959
  const resultPath = isMany ? `${childPath}.items` : childPath;
975
960
  out.push({
976
961
  propertyName: rel.propertyName,
@@ -1064,7 +1049,7 @@ function validateDepth(select, metadata, maxDepth, currentDepth = 1) {
1064
1049
  }
1065
1050
  function relationResultPath(parentPath, rel) {
1066
1051
  const base = `${parentPath}.${rel.propertyName}`;
1067
- return rel.type === "hasMany" ? `${base}.items` : base;
1052
+ return rel.type === "hasMany" || rel.type === "refs" ? `${base}.items` : base;
1068
1053
  }
1069
1054
  function stageRelations(relations, parentPath, plan2) {
1070
1055
  if (plan2 === void 0 || relations.length === 0) {
@@ -1188,6 +1173,95 @@ async function fetchBelongsToHasOneBatch(rel, rawParentItems, selectSpec, target
1188
1173
  );
1189
1174
  return results;
1190
1175
  }
1176
+ async function fetchRefsList(rel, rawParentItem, selectSpec, targetMeta, options, currentDepth, ownPath) {
1177
+ const refsBinding = rel.refs;
1178
+ if (!refsBinding) {
1179
+ throw new Error(
1180
+ `Relation '${rel.propertyName}' is type 'refs' but carries no refs binding.`
1181
+ );
1182
+ }
1183
+ const [targetKeyField] = Object.keys(rel.keyBinding);
1184
+ const rawList = rawParentItem[refsBinding.from];
1185
+ const elements = Array.isArray(rawList) ? rawList : [];
1186
+ const queryKeys = [];
1187
+ for (const element of elements) {
1188
+ const refValue = element != null && typeof element === "object" ? element[refsBinding.key] : element;
1189
+ if (refValue == null) continue;
1190
+ queryKeys.push({ [targetKeyField]: refValue });
1191
+ }
1192
+ if (queryKeys.length === 0) {
1193
+ return [];
1194
+ }
1195
+ const implicitKeys = getImplicitKeyFields(selectSpec.select, targetMeta);
1196
+ const projectionExtras = options.updatable ? [...implicitKeys, "PK", "SK"] : implicitKeys;
1197
+ const batchOps = planBatchGetForQueryKeys(
1198
+ targetMeta,
1199
+ queryKeys,
1200
+ selectSpec.select,
1201
+ projectionExtras.length > 0 ? projectionExtras : void 0
1202
+ );
1203
+ const executor = ClientManager.getExecutor();
1204
+ const queryKeyToRaw = /* @__PURE__ */ new Map();
1205
+ const mw = options.mw ?? NO_MIDDLEWARE;
1206
+ const opPath = [...options.relationPath ?? [], rel.propertyName];
1207
+ const opModel = mw.active ? ctxModelFor(rel.targetFactory()) : void 0;
1208
+ for (const operation of batchOps) {
1209
+ const items = mw.active ? await mw.runOp(
1210
+ operation,
1211
+ opPath,
1212
+ opModel,
1213
+ (op) => executor.batchGet(
1214
+ op,
1215
+ options.retry !== void 0 ? { retry: options.retry } : void 0
1216
+ ).then((r) => r.items)
1217
+ ) : (await executor.batchGet(
1218
+ operation,
1219
+ options.retry !== void 0 ? { retry: options.retry } : void 0
1220
+ )).items;
1221
+ for (const item of items) {
1222
+ for (const queryKey of queryKeys) {
1223
+ if (!matchesQueryKey(item, queryKey)) continue;
1224
+ queryKeyToRaw.set(serializeQueryKey(queryKey), item);
1225
+ }
1226
+ }
1227
+ }
1228
+ const nestedRelations = detectRelationFields(selectSpec.select, targetMeta);
1229
+ const seen = /* @__PURE__ */ new Set();
1230
+ const orderedKeys = [];
1231
+ for (const queryKey of queryKeys) {
1232
+ const serialized = serializeQueryKey(queryKey);
1233
+ if (seen.has(serialized)) continue;
1234
+ seen.add(serialized);
1235
+ orderedKeys.push(queryKey);
1236
+ }
1237
+ const resolved = orderedKeys.map(() => null);
1238
+ await mapWithConcurrency(
1239
+ orderedKeys,
1240
+ options.plan?.plan.concurrency ?? RELATION_TRAVERSAL_CONCURRENCY,
1241
+ async (queryKey, index) => {
1242
+ const raw = queryKeyToRaw.get(serializeQueryKey(queryKey));
1243
+ if (!raw) return;
1244
+ const updatable = options.updatable ?? false;
1245
+ let item = hydrate([raw], selectSpec.select, targetMeta, updatable)[0] ?? null;
1246
+ if (item && nestedRelations.length > 0) {
1247
+ item = await resolveRelations(
1248
+ item,
1249
+ raw,
1250
+ selectSpec.select,
1251
+ targetMeta,
1252
+ options,
1253
+ currentDepth + 1,
1254
+ ownPath
1255
+ );
1256
+ if (item && updatable) attachHiddenKey(item, raw);
1257
+ }
1258
+ if (item && passesPostLoad(item, selectSpec, targetMeta)) {
1259
+ resolved[index] = item;
1260
+ }
1261
+ }
1262
+ );
1263
+ return resolved.filter((item) => item !== null);
1264
+ }
1191
1265
  function passesPostLoad(item, spec, targetMeta) {
1192
1266
  if (spec.filter && !evaluateFilterClientSide(item, spec.filter, targetMeta)) {
1193
1267
  return false;
@@ -1270,6 +1344,21 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1270
1344
  };
1271
1345
  return;
1272
1346
  }
1347
+ if (rel.type === "refs") {
1348
+ const targetClass2 = rel.targetFactory();
1349
+ const targetMeta2 = MetadataRegistry.get(targetClass2);
1350
+ const items = await fetchRefsList(
1351
+ rel,
1352
+ rawParentItem,
1353
+ selectSpec,
1354
+ targetMeta2,
1355
+ opts,
1356
+ currentDepth,
1357
+ ownPath
1358
+ );
1359
+ result[rel.propertyName] = { items, cursor: null };
1360
+ return;
1361
+ }
1273
1362
  const targetClass = rel.targetFactory();
1274
1363
  const targetMeta = MetadataRegistry.get(targetClass);
1275
1364
  const [resolvedItem] = await fetchBelongsToHasOneBatch(
@@ -1329,6 +1418,11 @@ async function executeQueryInner(modelClass, key, selectSpec, options, mw) {
1329
1418
  const normalized = normalizeTopLevelSelect(selectSpec);
1330
1419
  const select = normalized.select;
1331
1420
  const filter = normalized.filter;
1421
+ validateInlineSnapshotSelect(
1422
+ select,
1423
+ metadata,
1424
+ (rel) => MetadataRegistry.get(rel.targetFactory())
1425
+ );
1332
1426
  const maxDepth = options?.maxDepth ?? 1;
1333
1427
  const relations = detectRelationFields(select, metadata);
1334
1428
  if (relations.length > 0) {
@@ -1346,8 +1440,13 @@ async function executeQueryInner(modelClass, key, selectSpec, options, mw) {
1346
1440
  });
1347
1441
  const operation = executionPlan.operations[0];
1348
1442
  const canParallelizeRelations = relations.length > 0 && relations.every(
1349
- (rel) => Object.values(rel.keyBinding).every(
1350
- (sourceField) => key[sourceField] != null
1443
+ (rel) => (
1444
+ // A `refs` relation (issue #197) resolves from the parent row's inline
1445
+ // id-list (`refs.from`), which only exists on the FETCHED parent — never the
1446
+ // input key — so it can never be dispatched before the parent read.
1447
+ rel.type !== "refs" && Object.values(rel.keyBinding).every(
1448
+ (sourceField) => key[sourceField] != null
1449
+ )
1351
1450
  )
1352
1451
  );
1353
1452
  const retry = options?.retry;
@@ -1887,7 +1986,7 @@ function deriveOneConditionCheck(fragment, req) {
1887
1986
  return { entity: { name: targetName, modelClass: targetClass }, keyBinding };
1888
1987
  }
1889
1988
  function deriveEdgeWrites(fragment, edges, lifecycle) {
1890
- return edges.map((edge) => deriveOneEdgeWrite(fragment, edge, lifecycle));
1989
+ return edges.flatMap((edge) => deriveOneEdgeWrite(fragment, edge, lifecycle));
1891
1990
  }
1892
1991
  function deriveOneEdgeWrite(fragment, edge, lifecycle) {
1893
1992
  const targetClass = edge.targetFactory();
@@ -1903,16 +2002,48 @@ function deriveOneEdgeWrite(fragment, edge, lifecycle) {
1903
2002
  );
1904
2003
  }
1905
2004
  const adjacencyClass = relation.targetFactory();
1906
- const items = deriveEdgeWriteItemsFor(
2005
+ if (edge.inverse === void 0) {
2006
+ const items = deriveEdgeWriteItemsFor(
2007
+ adjacencyClass,
2008
+ { targetFactory: edge.targetFactory, relationProperty: edge.relationProperty },
2009
+ lifecycle
2010
+ );
2011
+ return [
2012
+ {
2013
+ entity: { name: adjacencyClass.name, modelClass: adjacencyClass },
2014
+ relation: { target: targetClass.name, property: edge.relationProperty },
2015
+ items
2016
+ }
2017
+ ];
2018
+ }
2019
+ const inverseAdjacency = edge.inverse.adjacencyFactory();
2020
+ const allItems = deriveEdgeWriteItemsFor(
1907
2021
  adjacencyClass,
1908
- { targetFactory: edge.targetFactory, relationProperty: edge.relationProperty },
2022
+ {
2023
+ targetFactory: edge.targetFactory,
2024
+ relationProperty: edge.relationProperty,
2025
+ inverse: edge.inverse
2026
+ },
1909
2027
  lifecycle
1910
2028
  );
1911
- return {
1912
- entity: { name: adjacencyClass.name, modelClass: adjacencyClass },
1913
- relation: { target: targetClass.name, property: edge.relationProperty },
1914
- items
1915
- };
2029
+ const forwardItems = allItems.filter((it) => it.entity === adjacencyClass.name);
2030
+ const inverseItems = allItems.filter((it) => it.entity === inverseAdjacency.name);
2031
+ const inverseTargetClass = edge.inverse.targetFactory();
2032
+ return [
2033
+ {
2034
+ entity: { name: adjacencyClass.name, modelClass: adjacencyClass },
2035
+ relation: { target: targetClass.name, property: edge.relationProperty },
2036
+ items: forwardItems
2037
+ },
2038
+ {
2039
+ entity: { name: inverseAdjacency.name, modelClass: inverseAdjacency },
2040
+ relation: {
2041
+ target: inverseTargetClass.name,
2042
+ property: edge.inverse.relationProperty
2043
+ },
2044
+ items: inverseItems
2045
+ }
2046
+ ];
1916
2047
  }
1917
2048
  function deriveUpdates(fragment, derive) {
1918
2049
  return derive.map((d) => deriveOneUpdate(fragment, d));
@@ -3629,17 +3760,15 @@ function normalizeWriteOp(alias, d) {
3629
3760
  }
3630
3761
  return [{ ...base, key: d.key }];
3631
3762
  }
3632
- function compileWriteOp(op) {
3633
- const keyFieldNames2 = Object.keys(op.key);
3634
- const inputFieldNames = Object.keys(op.input);
3635
- const model = op.model;
3763
+ function compileWriteFragment(input) {
3764
+ const { alias, intent, model, keyFieldNames: keyFieldNames2, inputFieldNames } = input;
3636
3765
  const body = ($) => {
3637
3766
  const descriptor = {
3638
- [op.intent]: (() => model),
3767
+ [intent]: (() => model),
3639
3768
  key: mapToRefs($, keyFieldNames2),
3640
3769
  ...inputFieldNames.length > 0 ? { input: mapToRefs($, inputFieldNames) } : {}
3641
3770
  };
3642
- const map = { [op.alias]: descriptor };
3771
+ const map = { [alias]: descriptor };
3643
3772
  return map;
3644
3773
  };
3645
3774
  const plan2 = mutation(body);
@@ -3647,7 +3776,16 @@ function compileWriteOp(op) {
3647
3776
  throw new Error("DDBModel.mutate: internal error building the mutation plan.");
3648
3777
  }
3649
3778
  const compiled = compileMutationPlan(plan2);
3650
- const fragment = compiled.fragments[0];
3779
+ return compiled.fragments[0];
3780
+ }
3781
+ function compileWriteOp(op) {
3782
+ const fragment = compileWriteFragment({
3783
+ alias: op.alias,
3784
+ intent: op.intent,
3785
+ model: op.model,
3786
+ keyFieldNames: Object.keys(op.key),
3787
+ inputFieldNames: Object.keys(op.input)
3788
+ });
3651
3789
  const params = { ...op.input, ...op.key };
3652
3790
  return {
3653
3791
  fragment,
@@ -3716,6 +3854,445 @@ function resolveModelStatic(ref, alias) {
3716
3854
  return ref;
3717
3855
  }
3718
3856
 
3857
+ // src/runtime/prepared.ts
3858
+ var PREPARE_LABEL = "DDBModel.prepare";
3859
+ var PARAM_REF_BRAND = /* @__PURE__ */ Symbol("graphddb:preparedParamRef");
3860
+ function isPreparedParamRef(value) {
3861
+ return typeof value === "object" && value !== null && value[PARAM_REF_BRAND] === true;
3862
+ }
3863
+ function makeParamRef(name) {
3864
+ const origin = `the placeholder '${name}' (\`$.${name}\`)`;
3865
+ const target = /* @__PURE__ */ Object.create(null);
3866
+ const proxy = new Proxy(target, {
3867
+ get(_t, prop) {
3868
+ if (prop === PARAM_REF_BRAND) return true;
3869
+ if (prop === "name") return name;
3870
+ if (typeof prop === "symbol") return void 0;
3871
+ throw new Error(
3872
+ `${PREPARE_LABEL}: unsupported access '${String(prop)}' on ${origin}. A \`key\` / \`input\` / option value only supports a direct \`$.name\` param reference or a concrete static literal \u2014 never a transform, a nested access, or a coercion (that would capture a per-call runtime value).`
3873
+ );
3874
+ },
3875
+ set() {
3876
+ throw new Error(`${PREPARE_LABEL}: cannot assign on ${origin}.`);
3877
+ }
3878
+ });
3879
+ return proxy;
3880
+ }
3881
+ function makePreparedInputProxy() {
3882
+ const cache = /* @__PURE__ */ new Map();
3883
+ return new Proxy(/* @__PURE__ */ Object.create(null), {
3884
+ get(_t, prop) {
3885
+ if (typeof prop === "symbol") return void 0;
3886
+ const name = prop;
3887
+ let ref = cache.get(name);
3888
+ if (!ref) {
3889
+ ref = makeParamRef(name);
3890
+ cache.set(name, ref);
3891
+ }
3892
+ return ref;
3893
+ },
3894
+ set() {
3895
+ throw new Error(`${PREPARE_LABEL}: cannot assign to the \`$\` placeholder proxy.`);
3896
+ }
3897
+ });
3898
+ }
3899
+ function assertNoRuntimeCapture(value, where) {
3900
+ if (isPreparedParamRef(value)) return;
3901
+ if (value === null) return;
3902
+ const t = typeof value;
3903
+ if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return;
3904
+ if (value instanceof Date) return;
3905
+ if (t === "undefined") {
3906
+ throw new Error(
3907
+ `${PREPARE_LABEL}: ${where} is undefined. Bind a \`$.name\` param reference or a concrete static literal \u2014 or omit the field from the template entirely.`
3908
+ );
3909
+ }
3910
+ if (t === "function") {
3911
+ throw new Error(
3912
+ `${PREPARE_LABEL}: ${where} is a function. A prepared body may only bind a \`$.name\` param reference or a static literal here; a function is a captured per-call value (no runtime capture \u2014 see the prepared-statement contract).`
3913
+ );
3914
+ }
3915
+ if (t === "symbol") {
3916
+ throw new Error(`${PREPARE_LABEL}: ${where} is a symbol, which is not a bindable value.`);
3917
+ }
3918
+ throw new Error(
3919
+ `${PREPARE_LABEL}: ${where} is a non-literal object. A prepared body may only bind a \`$.name\` param reference or a static literal here \u2014 an object value is a captured per-call runtime value.`
3920
+ );
3921
+ }
3922
+ function recordSlots(binding, where) {
3923
+ const fields = Object.keys(binding);
3924
+ const slots = {};
3925
+ for (const f of fields) {
3926
+ const v = binding[f];
3927
+ if (isPreparedParamRef(v)) {
3928
+ slots[f] = { kind: "param", name: v.name };
3929
+ } else {
3930
+ assertNoRuntimeCapture(v, `${where} field '${f}'`);
3931
+ slots[f] = { kind: "literal", value: v };
3932
+ }
3933
+ }
3934
+ return { fields, slots };
3935
+ }
3936
+ function bindSlots(slots, params) {
3937
+ const out = {};
3938
+ for (const [field, slot] of Object.entries(slots)) {
3939
+ out[field] = slot.kind === "param" ? params[slot.name] : slot.value;
3940
+ }
3941
+ return out;
3942
+ }
3943
+ function bindOptionalSlot(slot, params) {
3944
+ if (slot === void 0) return void 0;
3945
+ return slot.kind === "param" ? params[slot.name] : slot.value;
3946
+ }
3947
+ function recordOptionSlot(value, where) {
3948
+ if (value === void 0) return void 0;
3949
+ if (isPreparedParamRef(value)) return { kind: "param", name: value.name };
3950
+ assertNoRuntimeCapture(value, where);
3951
+ return { kind: "literal", value };
3952
+ }
3953
+ var PreparedWriteStatement = class {
3954
+ /** @internal */
3955
+ constructor(ops) {
3956
+ this.ops = ops;
3957
+ }
3958
+ ops;
3959
+ async execute(params = {}, options = {}) {
3960
+ const compiled = this.ops.map((op) => ({
3961
+ fragment: op.fragment,
3962
+ params: { ...bindSlots(op.inputSlots, params), ...bindSlots(op.keySlots, params) },
3963
+ ...op.conditionSlots !== void 0 ? { condition: bindSlots(op.conditionSlots, params) } : {},
3964
+ ...op.result !== void 0 ? { result: op.result } : {}
3965
+ }));
3966
+ const mode = options.mode ?? "transaction";
3967
+ const runOpts = {
3968
+ label: PREPARE_LABEL,
3969
+ ...options.retry !== void 0 ? { retry: options.retry } : {},
3970
+ ...options.context !== void 0 ? { context: options.context } : {}
3971
+ };
3972
+ if (mode === "transaction") {
3973
+ const { readBacks } = await executeCompiledWriteOp(compiled, runOpts);
3974
+ const out2 = {};
3975
+ this.ops.forEach((op, i) => {
3976
+ if (op.result !== void 0) out2[op.alias] = readBacks[i];
3977
+ });
3978
+ return out2;
3979
+ }
3980
+ const settled = await executeCompiledParallelWrites(compiled, runOpts);
3981
+ const out = {};
3982
+ this.ops.forEach((op, i) => {
3983
+ const res = settled[i];
3984
+ out[op.alias] = res.ok ? { ok: true, value: op.result !== void 0 ? res.value : void 0 } : { ok: false, error: res.error };
3985
+ });
3986
+ return out;
3987
+ }
3988
+ };
3989
+ var PreparedReadStatement = class {
3990
+ /** @internal */
3991
+ constructor(routes) {
3992
+ this.routes = routes;
3993
+ }
3994
+ routes;
3995
+ async execute(params = {}, options = {}) {
3996
+ const results = await Promise.all(
3997
+ this.routes.map((route) => this.runRoute(route, params, options))
3998
+ );
3999
+ const out = {};
4000
+ this.routes.forEach((route, i) => {
4001
+ out[route.alias] = results[i];
4002
+ });
4003
+ return out;
4004
+ }
4005
+ runRoute(route, params, options) {
4006
+ const key = bindSlots(route.keySlots, params);
4007
+ const consistentRead = bindOptionalSlot(route.consistentReadSlot, params);
4008
+ if (route.kind === "query") {
4009
+ return executeQuery(route.modelClass, key, route.select, {
4010
+ consistentRead,
4011
+ maxDepth: route.maxDepth,
4012
+ ...options.retry !== void 0 ? { retry: options.retry } : {},
4013
+ ...options.context !== void 0 ? { context: options.context } : {}
4014
+ });
4015
+ }
4016
+ const limit = bindOptionalSlot(route.limitSlot, params);
4017
+ const after = bindOptionalSlot(route.afterSlot, params);
4018
+ return executeList(route.modelClass, key, route.select, {
4019
+ limit,
4020
+ after,
4021
+ order: route.order,
4022
+ filter: route.filter,
4023
+ ...options.retry !== void 0 ? { retry: options.retry } : {},
4024
+ ...options.context !== void 0 ? { context: options.context } : {}
4025
+ });
4026
+ }
4027
+ };
4028
+ var READ_INTENT_KEYS = ["query", "list"];
4029
+ function classify(routes) {
4030
+ let kind;
4031
+ for (const { alias, route } of routes) {
4032
+ const rec = route;
4033
+ const isRead = READ_INTENT_KEYS.some((k) => rec[k] !== void 0);
4034
+ const isWrite = INTENT_KEYS.some((k) => rec[k] !== void 0);
4035
+ if (isRead === isWrite) {
4036
+ throw new Error(
4037
+ `${PREPARE_LABEL}: the descriptor for alias '${alias}' must carry exactly one of a read intent (\`query\` / \`list\`) OR a write intent (\`create\` / \`update\` / \`remove\`). It declared ${isRead && isWrite ? "both" : "neither"}.`
4038
+ );
4039
+ }
4040
+ const routeKind = isRead ? "read" : "write";
4041
+ if (kind === void 0) kind = routeKind;
4042
+ else if (kind !== routeKind) {
4043
+ throw new Error(
4044
+ `${PREPARE_LABEL}: a prepared body must be all-read or all-write; alias '${alias}' is a ${routeKind} route but a previous alias was a ${kind} route. Split them into two prepared statements.`
4045
+ );
4046
+ }
4047
+ }
4048
+ if (kind === void 0) {
4049
+ throw new Error(
4050
+ `${PREPARE_LABEL}: the prepared body declared no routes. Declare at least one \`{ alias: { query|list|create|update|remove: () => Model, key, ... } }\`.`
4051
+ );
4052
+ }
4053
+ return kind;
4054
+ }
4055
+ function compileWriteRoute(alias, route) {
4056
+ const present = INTENT_KEYS.filter((k) => route[k] !== void 0);
4057
+ if (present.length !== 1) {
4058
+ throw new Error(
4059
+ `${PREPARE_LABEL}: the write descriptor for alias '${alias}' must carry exactly one intent (\`create\` / \`update\` / \`remove\`), but declared ${present.length === 0 ? "none" : present.join(", ")}.`
4060
+ );
4061
+ }
4062
+ const intent = present[0];
4063
+ if (route.key === null || typeof route.key !== "object") {
4064
+ throw new Error(
4065
+ `${PREPARE_LABEL}: the '${intent}' descriptor for alias '${alias}' must declare a \`key\` object binding the target's primary-key fields.`
4066
+ );
4067
+ }
4068
+ const model = resolveModelStatic(route[intent], alias);
4069
+ const keyRec = recordSlots(route.key, `${alias} key`);
4070
+ const inputRec = recordSlots(
4071
+ route.input ?? {},
4072
+ `${alias} input`
4073
+ );
4074
+ const conditionRec = route.condition !== void 0 ? recordSlots(route.condition, `${alias} condition`) : void 0;
4075
+ if (route.result !== void 0) assertStaticTemplate(route.result, `${alias} result`);
4076
+ const fragment = compileWriteFragment({
4077
+ alias,
4078
+ intent,
4079
+ model,
4080
+ keyFieldNames: keyRec.fields,
4081
+ inputFieldNames: inputRec.fields
4082
+ });
4083
+ return {
4084
+ alias,
4085
+ intent,
4086
+ fragment,
4087
+ keySlots: keyRec.slots,
4088
+ inputSlots: inputRec.slots,
4089
+ ...conditionRec !== void 0 ? { conditionSlots: conditionRec.slots } : {},
4090
+ ...route.result !== void 0 ? { result: route.result } : {}
4091
+ };
4092
+ }
4093
+ function compileReadRoute(alias, route) {
4094
+ const isQuery = route.query !== void 0;
4095
+ const isList = route.list !== void 0;
4096
+ if (isQuery === isList) {
4097
+ throw new Error(
4098
+ `${PREPARE_LABEL}: the read descriptor for alias '${alias}' must carry exactly one of \`query\` or \`list\`. It declared ${isQuery && isList ? "both" : "neither"}.`
4099
+ );
4100
+ }
4101
+ if (route.key === null || typeof route.key !== "object" || route.select === void 0) {
4102
+ throw new Error(
4103
+ `${PREPARE_LABEL}: the '${isQuery ? "query" : "list"}' descriptor for alias '${alias}' must declare both \`key\` and \`select\`.`
4104
+ );
4105
+ }
4106
+ const model = resolveModelStatic(isQuery ? route.query : route.list, alias);
4107
+ const modelClass = resolveModelClass(model);
4108
+ const keyRec = recordSlots(route.key, `${alias} key`);
4109
+ assertStaticTemplate(route.select, `${alias} select`);
4110
+ const opt = route.options ?? {};
4111
+ if (isQuery) {
4112
+ for (const unsupported of ["limit", "after", "order", "filter"]) {
4113
+ if (opt[unsupported] !== void 0) {
4114
+ throw new Error(
4115
+ `${PREPARE_LABEL}: the 'query' descriptor for alias '${alias}' declares \`options.${unsupported}\`, which a single-entity query does not support (a query supports \`consistentRead\` / \`maxDepth\`). Use a \`list\` route for pagination / ordering / filtering.`
4116
+ );
4117
+ }
4118
+ }
4119
+ } else if (opt.consistentRead !== void 0) {
4120
+ throw new Error(
4121
+ `${PREPARE_LABEL}: the 'list' descriptor for alias '${alias}' declares \`options.consistentRead\`, which \`Model.list\` does not support \u2014 it would be silently ignored. Remove it (a list always reads eventually consistent).`
4122
+ );
4123
+ }
4124
+ if (opt.filter !== void 0) {
4125
+ assertStaticTemplate(opt.filter, `${alias} options.filter`);
4126
+ }
4127
+ return {
4128
+ alias,
4129
+ kind: isQuery ? "query" : "list",
4130
+ modelClass,
4131
+ select: route.select,
4132
+ keySlots: keyRec.slots,
4133
+ ...typeof opt.maxDepth === "number" ? { maxDepth: opt.maxDepth } : {},
4134
+ ...opt.order !== void 0 ? { order: opt.order } : {},
4135
+ ...opt.filter !== void 0 ? { filter: opt.filter } : {},
4136
+ ...(() => {
4137
+ const s = recordOptionSlot(opt.consistentRead, `${alias} options.consistentRead`);
4138
+ return s !== void 0 ? { consistentReadSlot: s } : {};
4139
+ })(),
4140
+ ...(() => {
4141
+ const s = recordOptionSlot(opt.limit, `${alias} options.limit`);
4142
+ return s !== void 0 ? { limitSlot: s } : {};
4143
+ })(),
4144
+ ...(() => {
4145
+ const s = recordOptionSlot(opt.after, `${alias} options.after`);
4146
+ return s !== void 0 ? { afterSlot: s } : {};
4147
+ })()
4148
+ };
4149
+ }
4150
+ function assertStaticTemplate(value, where) {
4151
+ if (isPreparedParamRef(value)) {
4152
+ throw new Error(
4153
+ `${PREPARE_LABEL}: ${where} is a \`$\` param reference. This position is a static projection / template \u2014 only \`key\` (and the dynamic pagination options) may bind params.`
4154
+ );
4155
+ }
4156
+ if (typeof value === "function") {
4157
+ throw new Error(
4158
+ `${PREPARE_LABEL}: ${where} is a function \u2014 a captured per-call value (no runtime capture). This position must be a static, params-independent template.`
4159
+ );
4160
+ }
4161
+ if (value !== null && typeof value === "object") {
4162
+ if (Array.isArray(value)) {
4163
+ value.forEach((v, i) => assertStaticTemplate(v, `${where}[${i}]`));
4164
+ } else {
4165
+ for (const [k, v] of Object.entries(value)) {
4166
+ assertStaticTemplate(v, `${where}.${k}`);
4167
+ }
4168
+ }
4169
+ }
4170
+ }
4171
+ var PREPARE_CACHE_MAX = 256;
4172
+ var BoundedLru = class {
4173
+ constructor(max) {
4174
+ this.max = max;
4175
+ }
4176
+ max;
4177
+ map = /* @__PURE__ */ new Map();
4178
+ get(key) {
4179
+ const v = this.map.get(key);
4180
+ if (v !== void 0) {
4181
+ this.map.delete(key);
4182
+ this.map.set(key, v);
4183
+ }
4184
+ return v;
4185
+ }
4186
+ set(key, value) {
4187
+ if (this.map.has(key)) this.map.delete(key);
4188
+ this.map.set(key, value);
4189
+ if (this.map.size > this.max) {
4190
+ const oldest = this.map.keys().next().value;
4191
+ if (oldest !== void 0) this.map.delete(oldest);
4192
+ }
4193
+ }
4194
+ get size() {
4195
+ return this.map.size;
4196
+ }
4197
+ clear() {
4198
+ this.map.clear();
4199
+ }
4200
+ };
4201
+ var PREPARE_CACHE = new BoundedLru(PREPARE_CACHE_MAX);
4202
+ function structureKey(routes) {
4203
+ const parts = routes.map(({ alias, route }) => {
4204
+ const rec = route;
4205
+ const intent = [...INTENT_KEYS, ...READ_INTENT_KEYS].find((k) => rec[k] !== void 0) ?? "?";
4206
+ const modelRef = rec[intent];
4207
+ const modelId = modelIdentity(modelRef, alias);
4208
+ const key = describeBinding(route.key);
4209
+ const rw = route;
4210
+ const rr = route;
4211
+ const extra = intent === "query" || intent === "list" ? `select=${stableJson(rr.select)};opt=${describeReadOptions(rr.options)}` : `input=${describeBinding(rw.input)};cond=${describeBinding(rw.condition)};result=${stableJson(rw.result)}`;
4212
+ return `${alias}|${intent}|${modelId}|key=${key}|${extra}`;
4213
+ });
4214
+ return parts.join("||");
4215
+ }
4216
+ var MODEL_IDENTITY_IDS = /* @__PURE__ */ new WeakMap();
4217
+ var nextModelIdentityId = 1;
4218
+ function modelIdentity(ref, alias) {
4219
+ try {
4220
+ const model = resolveModelStatic(ref, alias);
4221
+ const cls = resolveModelClass(model);
4222
+ let id = MODEL_IDENTITY_IDS.get(cls);
4223
+ if (id === void 0) {
4224
+ id = nextModelIdentityId++;
4225
+ MODEL_IDENTITY_IDS.set(cls, id);
4226
+ }
4227
+ return `model#${id}`;
4228
+ } catch {
4229
+ return "unresolved";
4230
+ }
4231
+ }
4232
+ function describeBinding(binding) {
4233
+ if (binding === void 0) return "\u2205";
4234
+ const keys = Object.keys(binding).sort();
4235
+ return keys.map((k) => {
4236
+ const v = binding[k];
4237
+ return isPreparedParamRef(v) ? `${k}=$(${v.name})` : `${k}=L(${stableJson(v)})`;
4238
+ }).join(",");
4239
+ }
4240
+ function describeReadOptions(options) {
4241
+ if (options === void 0) return "\u2205";
4242
+ const o = options;
4243
+ const slot = (v) => isPreparedParamRef(v) ? `$(${v.name})` : v === void 0 ? "\u2205" : `L(${stableJson(v)})`;
4244
+ return `cr=${slot(o.consistentRead)};limit=${slot(o.limit)};after=${slot(o.after)};depth=${o.maxDepth ?? "\u2205"};order=${o.order ?? "\u2205"};filter=${stableJson(o.filter)}`;
4245
+ }
4246
+ function stableJson(value) {
4247
+ return JSON.stringify(value, (_k, v) => {
4248
+ if (isPreparedParamRef(v)) return `$${v.name}`;
4249
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
4250
+ const sorted = {};
4251
+ for (const k of Object.keys(v).sort()) {
4252
+ sorted[k] = v[k];
4253
+ }
4254
+ return sorted;
4255
+ }
4256
+ return v;
4257
+ });
4258
+ }
4259
+ function prepare(body) {
4260
+ if (typeof body !== "function") {
4261
+ throw new Error(
4262
+ `${PREPARE_LABEL}: expected a body function \`($) => ({ alias: { ... } })\`.`
4263
+ );
4264
+ }
4265
+ const $ = makePreparedInputProxy();
4266
+ const map = body($);
4267
+ if (map === null || typeof map !== "object") {
4268
+ throw new Error(
4269
+ `${PREPARE_LABEL}: the body must return a descriptor map \`{ alias: { ... } }\`.`
4270
+ );
4271
+ }
4272
+ const routes = Object.keys(map).map((alias) => ({
4273
+ alias,
4274
+ route: map[alias]
4275
+ }));
4276
+ const cacheKey = structureKey(routes);
4277
+ const cached = PREPARE_CACHE.get(cacheKey);
4278
+ if (cached !== void 0) return cached;
4279
+ const kind = classify(routes);
4280
+ let handle;
4281
+ if (kind === "write") {
4282
+ const ops = routes.map(
4283
+ ({ alias, route }) => compileWriteRoute(alias, route)
4284
+ );
4285
+ handle = new PreparedWriteStatement(ops);
4286
+ } else {
4287
+ const compiledRoutes = routes.map(
4288
+ ({ alias, route }) => compileReadRoute(alias, route)
4289
+ );
4290
+ handle = new PreparedReadStatement(compiledRoutes);
4291
+ }
4292
+ PREPARE_CACHE.set(cacheKey, handle);
4293
+ return handle;
4294
+ }
4295
+
3719
4296
  // src/model/DDBModel.ts
3720
4297
  var DDBModel = class {
3721
4298
  /**
@@ -3790,6 +4367,43 @@ var DDBModel = class {
3790
4367
  static mutate(envelope, options) {
3791
4368
  return executeWriteEnvelope(envelope, options);
3792
4369
  }
4370
+ /**
4371
+ * **Prepared statement** (issue #205 / design #203) — the read/write-unified
4372
+ * middle tier between the ad-hoc `DDBModel.mutate` / `DDBModel.query` (per-call
4373
+ * recompiled) and the public CQRS contract (`publicCommandModel` /
4374
+ * `publicQueryModel`, precompiled but contract-welded).
4375
+ *
4376
+ * `prepare($ => ({...}))` **compiles the declarative route body once** and returns
4377
+ * a handle whose `.execute(params)` binds the per-call values and runs through the
4378
+ * SAME execution cores those two surfaces use — so a write's derived maintainers /
4379
+ * atomic transaction semantics are identical to `DDBModel.mutate`, and a read's
4380
+ * projection / relation resolution / pagination are identical to `Model.query` /
4381
+ * `list`. A body is **all-read or all-write**:
4382
+ *
4383
+ * ```ts
4384
+ * const createPost = DDBModel.prepare(($) => ({
4385
+ * post: { create: () => Post, key: { threadId: $.threadId, postId: $.postId },
4386
+ * input: { body: $.body } },
4387
+ * }));
4388
+ * await createPost.execute({ threadId, postId, body });
4389
+ *
4390
+ * const userById = DDBModel.prepare(($) => ({
4391
+ * user: { query: () => User, key: { userId: $.userId },
4392
+ * select: { userId: true, name: true } },
4393
+ * }));
4394
+ * await userById.execute({ userId });
4395
+ * ```
4396
+ *
4397
+ * The body may reference only `$` placeholders (per-call values) and module-static
4398
+ * model refs — a captured per-call runtime value is a **loud reject** (the runtime
4399
+ * half of the no-runtime-capture rule; the static build lint is phase 2 / #206). A
4400
+ * handle hoisted to module scope recompiles nothing; an inline `prepare(fn)` hits a
4401
+ * bounded structural cache after the first compile (placement-independent safety
4402
+ * net).
4403
+ */
4404
+ static prepare(body) {
4405
+ return prepare(body);
4406
+ }
3793
4407
  /**
3794
4408
  * Parse a single CDC {@link ChangeEvent} into the `[oldRecord, newRecord]` tuple
3795
4409
  * for THIS model (issue #153 — the pure `(event) => [old, new]` typed mapper).
@@ -4178,12 +4792,12 @@ function wholeKeysSentinel() {
4178
4792
  function isContractKeyFieldRef(value) {
4179
4793
  return typeof value === "object" && value !== null && value[KEY_FIELD_REF_BRAND] === true;
4180
4794
  }
4181
- var PARAM_REF_BRAND = /* @__PURE__ */ Symbol("graphddb:contractParamRef");
4795
+ var PARAM_REF_BRAND2 = /* @__PURE__ */ Symbol("graphddb:contractParamRef");
4182
4796
  function isContractParamRef(value) {
4183
- return typeof value === "object" && value !== null && value[PARAM_REF_BRAND] === true;
4797
+ return typeof value === "object" && value !== null && value[PARAM_REF_BRAND2] === true;
4184
4798
  }
4185
4799
  function mintContractParamRef(field) {
4186
- return { [PARAM_REF_BRAND]: true, field, token: `{${field}}` };
4800
+ return { [PARAM_REF_BRAND2]: true, field, token: `{${field}}` };
4187
4801
  }
4188
4802
  var FROM_REF_BRAND = /* @__PURE__ */ Symbol("graphddb:contractFromRef");
4189
4803
  var COMPOSE_NODE_BRAND = /* @__PURE__ */ Symbol("graphddb:contractComposeNode");
@@ -4440,13 +5054,13 @@ function normalizeReturnSelection(select) {
4440
5054
  }
4441
5055
  return Object.keys(out).length > 0 ? out : void 0;
4442
5056
  }
4443
- var READ_INTENT_KEYS = ["query", "list"];
5057
+ var READ_INTENT_KEYS2 = ["query", "list"];
4444
5058
  var WRITE_INTENT_KEYS = ["create", "update", "remove"];
4445
5059
  function isPublicComposeDescriptor(value) {
4446
5060
  return typeof value === "object" && value !== null && isCommandPlan(value.command);
4447
5061
  }
4448
5062
  function isPublicReadDescriptor(value) {
4449
- return typeof value === "object" && value !== null && READ_INTENT_KEYS.some((k) => value[k] !== void 0);
5063
+ return typeof value === "object" && value !== null && READ_INTENT_KEYS2.some((k) => value[k] !== void 0);
4450
5064
  }
4451
5065
  function isPublicWriteDescriptor(value) {
4452
5066
  return typeof value === "object" && value !== null && WRITE_INTENT_KEYS.some((k) => value[k] !== void 0);
@@ -6669,6 +7283,11 @@ function buildRelationOperations(metadata, select, parentPath) {
6669
7283
  const targetClass = rel.targetFactory();
6670
7284
  const targetMeta = MetadataRegistry.get(targetClass);
6671
7285
  const childPath = `${parentPath}.${rel.propertyName}`;
7286
+ if (rel.type === "refs") {
7287
+ throw new Error(
7288
+ `Relation '${rel.propertyName}' is a 'refs' relation (issue #197): its read fans an inline parent-row id-list ('${rel.refs?.from}[].${rel.refs?.key}') out into one BatchGet. The static operations spec / Python codegen cannot represent a parent-list key fan-out (operation key conditions bind a single scalar '{result.*}'), so a 'refs' relation is resolvable ONLY through the TS in-process runtime (Model.query). Remove '${rel.propertyName}' from selects that are compiled to operations.json / generated Python, or resolve it with a separate BatchGet in the generated-code consumer.`
7289
+ );
7290
+ }
6672
7291
  if (rel.type === "hasMany") {
6673
7292
  const limit = spec.limit ?? rel.options?.limit?.default ?? 20;
6674
7293
  ops.push(
@@ -6946,8 +7565,6 @@ export {
6946
7565
  SPEC_VERSION,
6947
7566
  MARKER_ROW_ENTITY,
6948
7567
  buildManifest,
6949
- detectRelationFields,
6950
- getImplicitKeyFields,
6951
7568
  buildProjection,
6952
7569
  compileFilterExpression,
6953
7570
  evaluateFilter,
@@ -6984,6 +7601,11 @@ export {
6984
7601
  MAX_TRANSACT_COMPOSE_ITEMS,
6985
7602
  compileMutationPlan,
6986
7603
  executeCommandMethod,
7604
+ isPreparedParamRef,
7605
+ PreparedWriteStatement,
7606
+ PreparedReadStatement,
7607
+ PREPARE_CACHE_MAX,
7608
+ prepare,
6987
7609
  DDBModel,
6988
7610
  isMutationInputRef,
6989
7611
  isMutationFragment,