akanjs 3.0.0-alpha.92 → 3.0.0-alpha.94

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.
@@ -19,8 +19,10 @@ export interface CascadeWithPath {
19
19
  readonly refName: string | null;
20
20
  /** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
21
21
  readonly typeKey: string | null;
22
- /** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
22
+ /** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
23
23
  readonly typeValues: readonly string[];
24
+ /** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
25
+ readonly anyOwner: boolean;
24
26
  }
25
27
 
26
28
  const idNames = new Set(["ID", "String"]);
@@ -33,6 +35,7 @@ export class CascadePaths {
33
35
 
34
36
  collect(fieldMap: FieldObject) {
35
37
  for (const [key, field] of Object.entries(fieldMap)) {
38
+ this.#assertPolymorphicIsWiredUp(key, field);
36
39
  if (!field.cascade) continue;
37
40
  this.#assertKnownAction(key, field.cascade);
38
41
  if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
@@ -41,6 +44,16 @@ export class CascadePaths {
41
44
  return this;
42
45
  }
43
46
 
47
+ /** `polymorphic` widens one thing and nothing else, so anywhere else it is a declaration that does nothing. */
48
+ #assertPolymorphicIsWiredUp(key: string, field: ConstantField) {
49
+ if (!field.polymorphic) return;
50
+ if (field.cascade === "removeWith" && field.refPath) return;
51
+ throw new Error(
52
+ `Field "${key}" declares polymorphic: "${field.polymorphic}", which only widens a ` +
53
+ `cascade: "removeWith" field that names its owner type with refPath`,
54
+ );
55
+ }
56
+
44
57
  #assertKnownAction(key: string, action: CascadeAction) {
45
58
 
46
59
  if (!cascadeActions.includes(action)) {
@@ -64,10 +77,10 @@ export class CascadePaths {
64
77
  if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
65
78
  if (field.ref) {
66
79
  this.#assertHoldsId(key, field);
67
- return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [] };
80
+ return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [], anyOwner: false };
68
81
  }
69
82
  if (field.isClass && !field.isScalar) {
70
- return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [] };
83
+ return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [], anyOwner: false };
71
84
  }
72
85
  throw new Error(
73
86
  `Cascade field "${key}" declares cascade: "removeWith" but names no owner; make it a model reference, ` +
@@ -81,23 +94,44 @@ export class CascadePaths {
81
94
  const typeKey = field.refPath as string;
82
95
  const typeField = fieldMap[typeKey];
83
96
  if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
97
+ if (field.polymorphic === "any") return this.#readAnyOwner(key, typeKey, typeField);
84
98
 
85
99
  if (!typeField.enum) {
86
100
  throw new Error(
87
101
  `Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
88
- `refNames it may hold`,
102
+ `refNames it may hold; declare polymorphic: "any" to pay for a sweep on every removal instead`,
89
103
  );
90
104
  }
91
105
  const typeValues = typeField.enum.values.map((value) => String(value));
92
- return { key, modelRef: null, refName: null, typeKey, typeValues };
106
+ return { key, modelRef: null, refName: null, typeKey, typeValues, anyOwner: false };
107
+ }
108
+
109
+ #readAnyOwner(key: string, typeKey: string, typeField: ConstantField): CascadeWithPath {
110
+
111
+ if (typeField.enum) {
112
+ throw new Error(
113
+ `Cascade field "${key}" declares polymorphic: "any" and a refPath naming an enumOf(...); keep one`,
114
+ );
115
+ }
116
+
117
+ if (this.#primitiveNameOf(typeField) !== "String") {
118
+ throw new Error(
119
+ `Cascade field "${key}" declares polymorphic: "any", so refPath: "${typeKey}" must be a String field ` +
120
+ `holding the owner's refName`,
121
+ );
122
+ }
123
+ return { key, modelRef: null, refName: null, typeKey, typeValues: [], anyOwner: true };
93
124
  }
94
125
 
95
126
  #assertHoldsId(key: string, field: ConstantField) {
96
- const modelRef = field.modelRef as unknown as Cls;
97
- const refName = PrimitiveRegistry.has(modelRef)
98
- ? PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar)
99
- : null;
127
+ const refName = this.#primitiveNameOf(field);
100
128
  if (refName && idNames.has(refName)) return;
101
129
  throw new Error(`Cascade field "${key}" declares ref or refPath and must hold an ID`);
102
130
  }
131
+
132
+ #primitiveNameOf(field: ConstantField) {
133
+ const modelRef = field.modelRef as unknown as Cls;
134
+ if (!PrimitiveRegistry.has(modelRef)) return null;
135
+ return PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar);
136
+ }
103
137
  }
@@ -106,6 +106,11 @@ export interface ConstantFieldProps<
106
106
  validate?: (value: FieldValue, model: any) => boolean;
107
107
  text?: TextFieldRole;
108
108
  cascade?: CascadeAction;
109
+ /**
110
+ * Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
111
+ * the owner is whatever refName the row happens to hold, found by sweeping at removal time.
112
+ */
113
+ polymorphic?: "any";
109
114
  /**
110
115
  * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
111
116
  * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
@@ -204,6 +209,7 @@ interface ConstantFieldBuildProps<
204
209
  validate?: (value: FieldValue, model: any) => boolean;
205
210
  text?: TextFieldRole;
206
211
  cascade?: CascadeAction;
212
+ polymorphic?: "any";
207
213
  visual: boolean;
208
214
  modelRef: ConstantModelRef;
209
215
  arrDepth: number;
@@ -329,6 +335,7 @@ export class ConstantField<
329
335
  readonly validate?: (value: FieldValue, model: any) => boolean;
330
336
  readonly text?: TextFieldRole;
331
337
  readonly cascade?: CascadeAction;
338
+ readonly polymorphic?: "any";
332
339
  readonly visual: boolean;
333
340
  readonly modelRef: ConstantModelRef;
334
341
  readonly arrDepth: number;
@@ -362,6 +369,7 @@ export class ConstantField<
362
369
  this.validate = props.validate;
363
370
  this.text = props.text;
364
371
  this.cascade = props.cascade;
372
+ this.polymorphic = props.polymorphic;
365
373
  this.visual = props.visual;
366
374
  this.modelRef = props.modelRef;
367
375
  this.arrDepth = props.arrDepth;
@@ -442,6 +450,7 @@ export class ConstantField<
442
450
  validate: option.validate,
443
451
  text: option.text,
444
452
  cascade: option.cascade,
453
+ polymorphic: option.polymorphic,
445
454
  visual: option.visual ?? false,
446
455
  modelRef,
447
456
  arrDepth: arrDepth,
@@ -489,6 +498,7 @@ export class ConstantField<
489
498
  validate: this.validate,
490
499
  text: this.text,
491
500
  cascade: this.cascade,
501
+ polymorphic: this.polymorphic,
492
502
  visual: this.visual,
493
503
  modelRef: this.modelRef,
494
504
  arrDepth: this.arrDepth,
@@ -39,6 +39,10 @@ const buildPlan = (fieldObj: FieldObject): DefaultPlan => {
39
39
  if (field.fieldType === "hidden" || field.fieldType === "secret") shared[key] = null;
40
40
  else if (field.default !== undefined && field.default !== null) {
41
41
  if (typeof field.default === "function") perCall.set(key, field.default as () => unknown);
42
+ else if (Array.isArray(field.default)) {
43
+ const items = field.default as unknown[];
44
+ perCall.set(key, () => [...items]);
45
+ }
42
46
  else shared[key] = field.default as object;
43
47
  } else if (field.isArray) perCall.set(key, () => []);
44
48
  else if (field.nullable) shared[key] = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.92",
3
+ "version": "3.0.0-alpha.94",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -71,7 +71,8 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
71
71
  /**
72
72
  * The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
73
73
  * `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
74
- * inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
74
+ * inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
75
+ * `polymorphic: "any"` names no module at all.
75
76
  */
76
77
  export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
77
78
  const dependencies = new Set<string>();
@@ -79,7 +80,7 @@ export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) =>
79
80
  const { cascade } = mod.constant.full;
80
81
  for (const modelRef of cascade.removeRef.values()) dependencies.add(ConstantRegistry.getRefName(modelRef));
81
82
  for (const path of cascade.removeWith.values()) {
82
- if (path.typeValues.length) continue;
83
+ if (path.anyOwner || path.typeValues.length) continue;
83
84
  dependencies.add(path.refName ?? ConstantRegistry.getRefName(path.modelRef as never));
84
85
  }
85
86
  return dependencies;
@@ -43,6 +43,8 @@ const drainSize = 200;
43
43
  export class CascadeRunner {
44
44
  readonly #modules = new Map<string, CascadeModule>();
45
45
  readonly #plans = new Map<string, CascadePlan>();
46
+ /** `polymorphic: "any"` edges: the owner is unknowable at boot, so every model's removal has to sweep them. */
47
+ readonly #anyEdges: WithEdge[] = [];
46
48
  readonly #bulk = new Set<string>();
47
49
  readonly #context = new AsyncLocalStorage<CascadeContext>();
48
50
  readonly #logger = new Logger("Cascade");
@@ -71,7 +73,8 @@ export class CascadeRunner {
71
73
 
72
74
  async run(refName: string, doc: Record<string, unknown>) {
73
75
  const plan = this.#plans.get(refName);
74
- if (!plan?.refEdges.length && !plan?.withEdges.length) return;
76
+ if (!plan) return;
77
+ if (!plan.refEdges.length && !plan.withEdges.length && !this.#anyEdges.length) return;
75
78
  const parent = this.#context.getStore();
76
79
  const seen = parent?.seen ?? new Set<string>();
77
80
  const depth = (parent?.depth ?? 0) + 1;
@@ -83,7 +86,9 @@ export class CascadeRunner {
83
86
  }
84
87
  await this.#context.run({ seen, depth }, async () => {
85
88
  for (const edge of plan.refEdges) await this.#removeRef(edge, doc, seen);
86
- if (id) for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
89
+ if (!id) return;
90
+ for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
91
+ for (const edge of this.#anyEdges) await this.#removeWith(edge, refName, id, seen);
87
92
  });
88
93
  }
89
94
 
@@ -136,6 +141,10 @@ export class CascadeRunner {
136
141
 
137
142
  #collectWithEdges(childRef: string, mod: CascadeModule) {
138
143
  for (const [key, path] of mod.constant.full.cascade.removeWith) {
144
+ if (path.anyOwner) {
145
+ this.#anyEdges.push({ refName: childRef, key, typeKey: path.typeKey });
146
+ continue;
147
+ }
139
148
  for (const owner of this.#resolveOwners(childRef, key, path)) {
140
149
  this.#plans.get(owner)?.withEdges.push({ refName: childRef, key, typeKey: path.typeKey });
141
150
  }
@@ -163,6 +172,8 @@ export class CascadeRunner {
163
172
  #hasRemoveSideEffect(refName: string) {
164
173
  const mod = this.#modules.get(refName);
165
174
  if (!mod) return true;
175
+
176
+ if (this.#anyEdges.length) return true;
166
177
  if (mod.schema.preHooks.get("remove")?.length || mod.schema.postHooks.get("remove")?.length) return true;
167
178
  if ((mod.srvRef as unknown as { [LIBS_REMOVE_HOOK]?: boolean })[LIBS_REMOVE_HOOK]) return true;
168
179
  const proto = mod.srvRef.prototype as { _preRemove?: unknown; _postRemove?: unknown };
@@ -184,10 +195,20 @@ export class CascadeRunner {
184
195
  lines.push(`${refName} removeWith ${edge.refName}.${path} (${this.#strategy(edge.refName)})`);
185
196
  }
186
197
  }
198
+ for (const edge of this.#anyEdges) {
199
+ lines.push(`<any> removeWith ${edge.refName}.${edge.key}+${edge.typeKey} (${this.#strategy(edge.refName)})`);
200
+ }
187
201
  if (!lines.length) return;
188
202
  const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
189
203
  this.#logger.verbose(`${lines.length} cascade edge(s), ${bulk} in one query`);
190
204
  for (const line of lines) this.#logger.verbose(line);
205
+
206
+ if (!this.#anyEdges.length) return;
207
+ const wildcards = this.#anyEdges.map((edge) => `${edge.refName}.${edge.key}`).join(", ");
208
+ this.#logger.info(
209
+ `${this.#anyEdges.length} wildcard removeWith edge(s) (${wildcards}): every removal probes them, ` +
210
+ `and no cascade removes in one query`,
211
+ );
191
212
  }
192
213
 
193
214
  #strategy(refName: string) {
@@ -1,4 +1,4 @@
1
- import { type BackendEnv, type Cls, INJECT_META } from "akanjs/base";
1
+ import { type BackendEnv, type Cls, INJECT_META, PrimitiveRegistry } from "akanjs/base";
2
2
  import {
3
3
  type ConstantFieldTypeInput,
4
4
  ConstantRegistry,
@@ -389,6 +389,9 @@ export const injectionBuilder = (parentRefName: string) => ({
389
389
  throw new Error("get and set should be both provided or not provided");
390
390
  const isMap = modelRef === Map;
391
391
  if (isMap && !opts.of) throw new Error("of should be provided when modelRef is Map");
392
+ const valueRef = (isMap ? opts.of : modelRef) as Cls;
393
+
394
+ const isStructured = Array.isArray(valueRef) || !PrimitiveRegistry.has(valueRef);
392
395
  type FieldValue = never extends GetFn ? GetFieldValue<ValueRef, ExplicitType, MapValue> : ReturnType<GetFn>;
393
396
  type MapFieldValue = never extends GetFn ? FieldToValue<MapValue> : ReturnType<GetFn>;
394
397
  type IsNullable = DefaultValue extends never ? true : false;
@@ -425,16 +428,14 @@ export const injectionBuilder = (parentRefName: string) => ({
425
428
  >("memory", {
426
429
  local: opts.local,
427
430
  get: (serializedValue: never) => {
428
- const rawValue = serializedValue as object | null;
429
- return (
430
- ConstantRegistry.deserialize(isMap ? (opts.of as Cls) : (modelRef as Cls), rawValue ?? opts.default, true) ??
431
- null
432
- );
431
+ const stored = serializedValue as unknown;
432
+
433
+ const rawValue = isStructured && typeof stored === "string" ? JSON.parse(stored) : stored;
434
+ return ConstantRegistry.deserialize(valueRef, (rawValue as object | null) ?? opts.default, true) ?? null;
433
435
  },
434
436
  set: (value: never) => {
435
- return (
436
- ConstantRegistry.serialize(isMap ? (opts.of as Cls) : (modelRef as Cls), value, true) ?? opts.default ?? null
437
- );
437
+ const serialized = ConstantRegistry.serialize(valueRef, value, true) ?? opts.default ?? null;
438
+ return isStructured ? JSON.stringify(serialized) : serialized;
438
439
  },
439
440
  default: opts.default as unknown,
440
441
  isMap,
@@ -44,6 +44,8 @@ import {
44
44
  import { UpdateCompiler } from "./UpdateCompiler";
45
45
  import { decodeDateValue, encodeSqlValue, jsonStr } from "./values";
46
46
 
47
+ const freshDefault = (value: unknown) => (Array.isArray(value) ? [...(value as unknown[])] : value);
48
+
47
49
  export class SqlDocumentStore {
48
50
  readonly schema: DocumentSchema;
49
51
  readonly table: string;
@@ -331,7 +333,10 @@ export class SqlDocumentStore {
331
333
  const value = data[key];
332
334
  if (value === undefined) {
333
335
  if (props.default !== undefined && props.default !== null) {
334
- doc[key] = typeof props.default === "function" ? props.default(data) : props.default;
336
+ doc[key] = freshDefault(typeof props.default === "function" ? props.default(data) : props.default);
337
+ } else if (props.isClass && props.isScalar && !props.nullable) {
338
+
339
+ doc[key] = getDefault((props.modelRef as { [FIELD_META]: FieldMap })[FIELD_META] as never);
335
340
  } else if (!props.nullable && !["removedAt"].includes(key)) {
336
341
  if (["id", "createdAt", "updatedAt"].includes(key)) continue;
337
342
  throw new Error(`Missing required field: ${key}`);
@@ -597,9 +602,12 @@ export class SqlDocumentStore {
597
602
  if (value === undefined) {
598
603
  const def = props.default;
599
604
  if (def != null) {
600
- result[key] = typeof def === "function" ? (def as (data: unknown) => unknown)(payload) : def;
605
+ result[key] = freshDefault(typeof def === "function" ? (def as (data: unknown) => unknown)(payload) : def);
601
606
  } else if (props.nullable) {
602
607
  result[key] = null;
608
+ } else if (props.isClass && props.isScalar) {
609
+
610
+ result[key] = getDefault((props.modelRef as { [FIELD_META]: FieldMap })[FIELD_META] as never);
603
611
  } else {
604
612
  result[key] =
605
613
  ((props as Record<string, unknown>).modelRef as { [DEFAULT_VALUE]?: unknown })?.[DEFAULT_VALUE] ?? null;
@@ -16,8 +16,10 @@ export interface CascadeWithPath {
16
16
  readonly refName: string | null;
17
17
  /** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
18
18
  readonly typeKey: string | null;
19
- /** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
19
+ /** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
20
20
  readonly typeValues: readonly string[];
21
+ /** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
22
+ readonly anyOwner: boolean;
21
23
  }
22
24
  export declare class CascadePaths {
23
25
  #private;
@@ -47,6 +47,11 @@ export interface ConstantFieldProps<FieldType extends ConstantFieldKind = Consta
47
47
  validate?: (value: FieldValue, model: any) => boolean;
48
48
  text?: TextFieldRole;
49
49
  cascade?: CascadeAction;
50
+ /**
51
+ * Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
52
+ * the owner is whatever refName the row happens to hold, found by sweeping at removal time.
53
+ */
54
+ polymorphic?: "any";
50
55
  /**
51
56
  * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
52
57
  * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
@@ -101,6 +106,7 @@ interface ConstantFieldBuildProps<FieldType extends ConstantFieldKind = any, Fie
101
106
  validate?: (value: FieldValue, model: any) => boolean;
102
107
  text?: TextFieldRole;
103
108
  cascade?: CascadeAction;
109
+ polymorphic?: "any";
104
110
  visual: boolean;
105
111
  modelRef: ConstantModelRef;
106
112
  arrDepth: number;
@@ -147,6 +153,7 @@ export declare class ConstantField<FieldType extends ConstantFieldKind = Constan
147
153
  readonly validate?: (value: FieldValue, model: any) => boolean;
148
154
  readonly text?: TextFieldRole;
149
155
  readonly cascade?: CascadeAction;
156
+ readonly polymorphic?: "any";
150
157
  readonly visual: boolean;
151
158
  readonly modelRef: ConstantModelRef;
152
159
  readonly arrDepth: number;
@@ -18,7 +18,8 @@ export declare const getModuleDependencyRefNames: (mod: DatabaseModule | Service
18
18
  /**
19
19
  * The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
20
20
  * `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
21
- * inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
21
+ * inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
22
+ * `polymorphic: "any"` names no module at all.
22
23
  */
23
24
  export declare const getModuleCascadeRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
24
25
  export interface Registration {