@drhomet/mithaq 0.1.1 → 0.2.1

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.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +974 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +283 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1094 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -1,6 +1,9 @@
1
1
  import { type FieldList, type RowFieldWhere } from './rows.ts';
2
2
  import type { Ref } from './user.ts';
3
- import { type ContextOf, type DefaultLists, type OperationDeclaration, type SchemasOf } from '../asas/runner/steps.ts';
3
+ import { defaultGlobalOperations, defaultOperations, type ContextOf, type DefaultLists, type OperationDeclaration, type SchemasOf } from '../asas/runner/steps.ts';
4
+ import { defaultFields, defaultGlobalFields } from '../base/fields.ts';
5
+ import { draftFields, versionOperations } from '../versions/versions.ts';
6
+ import type { KayanVersions } from '../versions/index.ts';
4
7
  import type { DerivedInput, OperationParsed } from '../asas/runner/write.ts';
5
8
  import type { Language } from './language.ts';
6
9
  import type { ActorBoundAccess, Access, Field, FieldOf, Indexes, KayanService, KindValue, LocalesOf, MessageTree, RuntimeField, ServiceOf } from './types.ts';
@@ -14,6 +17,7 @@ type KayanAuthOf<Targets> = AuthStrategy | {
14
17
  server: (targets: Targets, vocabulary: {
15
18
  catalog: readonly string[];
16
19
  statements: Readonly<Record<string, readonly string[]>>;
20
+ referenced: (id: string) => Promise<number>;
17
21
  }) => unknown;
18
22
  };
19
23
  type StrayLocales<Copy, Locale extends string> = {
@@ -125,6 +129,45 @@ type NamedSchema<Name> = [Name] extends [undefined] ? {
125
129
  } : {
126
130
  schema: Name;
127
131
  };
132
+ type CheckedFields<L extends Language, Fields extends Record<string, FieldOf<L>>> = Fields & {
133
+ [N in keyof Fields]: {
134
+ [Key in Exclude<keyof Fields[N], keyof Field<string, L['rules']>>]: never;
135
+ } & StrayLocales<Fields[N]['label'], LocalesOf<L>> & StrayDescription<Fields[N], LocalesOf<L>> & StrayPlaceholder<Fields[N], LocalesOf<L>> & TenantBinding<L, Fields[N]>;
136
+ };
137
+ type AnyFunction = (...args: never) => unknown;
138
+ /**
139
+ * `operations` written as data `O`, or as a function of the defaults whose result is `R`. The two
140
+ * are told apart by `O` having no keys, never by a union: under a union TypeScript stops checking a
141
+ * written object for excess properties, and a misspelled key would pass. A stray key in a returned
142
+ * object, which no excess check reaches, is refused by name.
143
+ */
144
+ type DeclaredOperations<L extends Language, Fields extends Record<string, RuntimeField>, O, R, Defaults> = O & ([keyof O] extends [never] ? ((args: {
145
+ defaults: Defaults;
146
+ }) => R & NoInfer<Record<string, OperationOf<L, Fields, never>> & {
147
+ [Name in keyof R]: OperationOf<L, Fields, DataOf<L, Fields, R[Name]>> & {
148
+ [Key in Exclude<keyof R[Name], keyof OperationOf<L, Fields, never>>]?: never;
149
+ };
150
+ }>) | NoInfer<Record<string, OperationOf<L, Fields, never>>> : NoInfer<Record<string, OperationOf<L, Fields, never>>> & NoInfer<{
151
+ [Name in keyof O]: OperationOf<L, Fields, DataOf<L, Fields, O[Name]>>;
152
+ }>);
153
+ /** The operations a kayan declared, whichever way it wrote them. */
154
+ type OperationsOf<O, R> = [keyof O] extends [never] ? ([O] extends [AnyFunction] ? R : O) : O;
155
+ /** Data, or a function of what the language offers that returns it, run once where the kayan is declared. */
156
+ type OverDefaults<Declared, Defaults> = Declared | ((args: {
157
+ defaults: Defaults;
158
+ }) => Declared);
159
+ /** The name of the language's versions target, which a kayan keeps versions through by writing an entry under it. */
160
+ type VersionsKey<L extends Language> = {
161
+ [Name in keyof L['targets']]: L['targets'][Name] extends {
162
+ capability: 'versions';
163
+ } ? Name : never;
164
+ }[keyof L['targets']];
165
+ /** What `fields: ({ defaults }) => ...` is handed: the id and timestamps, and the status field of a kayan that keeps drafts. */
166
+ type FieldDefaults<Kept> = typeof defaultFields & (Kept extends {
167
+ readonly drafts: true | object;
168
+ } ? typeof draftFields : unknown);
169
+ /** What `operations: ({ defaults }) => ...` is handed: the shipped operations, and the version ones of a kayan that keeps versions. */
170
+ type OperationDefaults<Kept> = typeof defaultOperations & ([Kept] extends [undefined] ? unknown : typeof versionOperations);
128
171
  type Paths<Fields> = Extract<keyof Fields, string> | {
129
172
  [Name in Extract<keyof Fields, string>]: Fields[Name] extends {
130
173
  relationTo: () => {
@@ -160,7 +203,7 @@ type CheckedRows<AccessDef, Fields, L extends Language> = AccessDef extends {
160
203
  };
161
204
  } : unknown;
162
205
  /** Bound to one language, so a kayan is checked against that language's kinds. */
163
- export declare const defineKayan: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> = Record<never, never>, const AccessDef extends ({
206
+ export declare const defineKayan: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> | AnyFunction = Record<never, never>, const AccessDef extends ({
164
207
  create?: Access;
165
208
  read?: Access;
166
209
  update?: Access;
@@ -176,12 +219,16 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
176
219
  read?: Access;
177
220
  update?: Access;
178
221
  delete?: Access;
179
- }, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const Auth extends KayanAuthOf<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined>(kayan: {
222
+ }, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const Auth extends KayanAuthOf<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined, const Kept extends KayanVersions | undefined = undefined, const R = Record<never, never>>(kayan: {
180
223
  name: Name;
181
224
  /** The provider that authenticates this kayan: `betterAuth({ server })`, `local({ secret })`, or a strategy. */
182
225
  auth?: Auth;
183
- /** Every key on a field is one the language named: a stray one is `never`. */
184
- fields: Fields & { [N in keyof Fields]: { [Key in Exclude<keyof Fields[N], keyof Field<string, L['rules']>>]: never; } & StrayLocales<Fields[N]['label'], LocalesOf<L>> & StrayDescription<Fields[N], LocalesOf<L>> & StrayPlaceholder<Fields[N], LocalesOf<L>> & TenantBinding<L, Fields[N]>; };
226
+ /**
227
+ * The fields, in column order: `{ ...defaultFields, title }`, or `({ defaults }) => ({ ...defaults, title })`.
228
+ * TypeScript types a callback beside them that reads the fields, such as an operation's `input`
229
+ * over the derived shapes, before it reads what the function returns, so such a kayan writes them as an object.
230
+ */
231
+ fields: OverDefaults<CheckedFields<L, Fields>, FieldDefaults<Kept>>;
185
232
  /** This kayan's own copy, nested by the parts of each key. A key a field's own `label` also claims is a collision. */
186
233
  messages?: Messages & { [Key in keyof Messages]: StrayMessages<Messages[Key], LocalesOf<L>>; };
187
234
  /** Where `mithaq generate messages` writes this kayan's file, if not the default. */
@@ -189,14 +236,14 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
189
236
  /** What each target of this build says about this kayan, by the target's name: `{ drizzle: pgTable(...) }`, `targets.<screen>`, `targets.service`. */
190
237
  targets?: KayanTargetsOf<L, Fields, {
191
238
  service?: {
192
- /** Every operation this kayan answers, and nothing else: `{ ...defaultOperations, archive }`. */
193
- operations?: O & NoInfer<Record<string, OperationOf<L, Fields, never>>> & NoInfer<{ [Name_1 in keyof O]: OperationOf<L, Fields, DataOf<L, Fields, O[Name_1]>>; }>;
239
+ /** Every operation this kayan answers, and nothing else: `{ ...defaultOperations, archive }`, or `({ defaults }) => ({ ...defaults, archive })`. */
240
+ operations?: DeclaredOperations<L, Fields, O, R, OperationDefaults<Kept>>;
194
241
  /** The store this kayan is written through. One of the language's targets. */
195
242
  store?: StoreName;
196
243
  /** The target a write is validated against. One of the language's targets. */
197
244
  schema?: SchemaName;
198
245
  };
199
- }>;
246
+ }> & { [Name_1 in VersionsKey<L>]?: Kept; };
200
247
  /** This kayan's own open bag, for the project's own code. Nothing in mithaq reads it, and it stays on the server. */
201
248
  custom?: Record<string, unknown>;
202
249
  access?: AccessDef & CheckedRows<AccessDef, Fields, L>;
@@ -221,7 +268,7 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
221
268
  out?: string;
222
269
  targets: KayanTargetsOf<L, Fields, {
223
270
  service: {
224
- operations: NoInfer<O>;
271
+ operations: NoInfer<OperationsOf<O, R>>;
225
272
  } & NamedStore<StoreName> & NamedSchema<SchemaName>;
226
273
  }>;
227
274
  custom?: Record<string, unknown>;
@@ -242,10 +289,10 @@ type GlobalAccess = {
242
289
  * Declares a global, a kayan of one row. It spreads `defaultGlobalFields` and runs
243
290
  * `defaultGlobalOperations`; a tenant field makes it one row per tenant.
244
291
  */
245
- export declare const defineGlobal: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> = Record<never, never>, const AccessDef extends GlobalAccess = Record<never, never>, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined>(global: {
292
+ export declare const defineGlobal: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> | AnyFunction = Record<never, never>, const AccessDef extends GlobalAccess = Record<never, never>, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined, const R = Record<never, never>>(global: {
246
293
  name: Name;
247
- /** Every key on a field is one the language named: a stray one is `never`. */
248
- fields: Fields & { [N in keyof Fields]: { [Key in Exclude<keyof Fields[N], keyof Field<string, L['rules']>>]: never; } & StrayLocales<Fields[N]['label'], LocalesOf<L>> & StrayDescription<Fields[N], LocalesOf<L>> & StrayPlaceholder<Fields[N], LocalesOf<L>> & TenantBinding<L, Fields[N]>; };
294
+ /** The fields, in column order: `{ ...defaultFields, ...defaultGlobalFields, siteTitle }`, or `({ defaults }) => ({ ...defaults, siteTitle })`. */
295
+ fields: OverDefaults<CheckedFields<L, Fields>, typeof defaultFields & typeof defaultGlobalFields>;
249
296
  /** This global's own copy, nested by the parts of each key, as on any kayan. */
250
297
  messages?: Messages & { [Key in keyof Messages]: StrayMessages<Messages[Key], LocalesOf<L>>; };
251
298
  /** Where `mithaq generate messages` writes this global's file, if not the default. */
@@ -253,8 +300,8 @@ export declare const defineGlobal: <const L extends Language>(language: L) => <c
253
300
  /** What each target says about this global, exactly as on any kayan. */
254
301
  targets?: KayanTargetsOf<L, Fields, {
255
302
  service?: {
256
- /** The two operations a global runs, `defaultGlobalOperations` or a list of the same names. */
257
- operations?: O;
303
+ /** The two operations a global runs, `defaultGlobalOperations` or a list of the same names, or `({ defaults }) => defaults`. */
304
+ operations?: DeclaredOperations<L, Fields, O, R, typeof defaultGlobalOperations>;
258
305
  /** The store this global is written through. One of the language's targets. */
259
306
  store?: StoreName;
260
307
  /** The target a write is validated against. One of the language's targets. */
@@ -279,7 +326,7 @@ export declare const defineGlobal: <const L extends Language>(language: L) => <c
279
326
  out?: string;
280
327
  targets: KayanTargetsOf<L, Fields, {
281
328
  service: {
282
- operations: NoInfer<O>;
329
+ operations: NoInfer<OperationsOf<O, R>>;
283
330
  } & NamedStore<StoreName> & NamedSchema<SchemaName>;
284
331
  }>;
285
332
  custom?: Record<string, unknown>;
@@ -1,10 +1,16 @@
1
1
  import { applyRows, rowFields, rowsOf } from './rows.js';
2
- import { languageRows, serviceTargetOf, tenantDefaults } from './language.js';
3
- import { resolveOperations } from '../asas/runner/steps.js';
2
+ import { isVersions, languageRows, serviceTargetOf, tenantDefaults } from './language.js';
3
+ import { defaultGlobalOperations, defaultOperations, resolveOperations } from '../asas/runner/steps.js';
4
+ import { defaultFields, defaultGlobalFields } from '../base/fields.js';
5
+ import { draftFields, versionOperations } from '../versions/versions.js';
6
+ const overDefaults = (declared, defaults) => typeof declared === 'function' ? declared({ defaults }) : declared;
4
7
  /** Bound to one language, so a kayan is checked against that language's kinds. */
5
8
  export const defineKayan = (language) => (kayan) => {
6
- // The key order is the column order: nothing is merged in and nothing is moved.
7
- const fields = Object.fromEntries(Object.entries(kayan.fields).map(([name, field]) => [name, tenantDefaults(language, field)]));
9
+ const versioning = Object.keys(language.targets).find((name) => isVersions(language.targets[name]));
10
+ const kept = versioning === undefined ? undefined : kayan.targets?.[versioning];
11
+ const declaredFields = overDefaults(kayan.fields, { ...defaultFields, ...(kept?.drafts && draftFields) });
12
+ // The key order is the column order but for the timestamps, which a new table puts last: nothing is merged in.
13
+ const fields = Object.fromEntries(Object.entries(declaredFields).map(([name, field]) => [name, tenantDefaults(language, field)]));
8
14
  // A tenant-owned kayan inherits the language's rows; one opts out on its access.
9
15
  const declared = kayan.access;
10
16
  const owned = Object.values(fields).some((field) => field.targets?.tenant !== undefined);
@@ -22,7 +28,8 @@ export const defineKayan = (language) => (kayan) => {
22
28
  }
23
29
  // A steps function runs once, here, so the kayan carries data a tool reads without calling anything.
24
30
  const own = kayan.targets?.service;
25
- const operations = resolveOperations(own?.operations, serviceTargetOf(language));
31
+ const declaredOperations = overDefaults(own?.operations, { ...defaultOperations, ...(kept && versionOperations) });
32
+ const operations = resolveOperations(declaredOperations, serviceTargetOf(language));
26
33
  return {
27
34
  ...kayan,
28
35
  ...(compiled ? { access: applyRows(compiled, declared) } : {}),
@@ -36,8 +43,15 @@ export const defineKayan = (language) => (kayan) => {
36
43
  */
37
44
  export const defineGlobal = (language) => {
38
45
  const kayan = defineKayan(language);
39
- return (global) =>
40
- // `defineKayan`'s own access object, untouched: a rebuilt one would drop the rows
41
- // marker, and the permission check would stand aside for every global operation.
42
- ({ ...kayan(global), single: true });
46
+ return (global) => {
47
+ const service = global.targets?.service;
48
+ const declared = {
49
+ ...global,
50
+ fields: overDefaults(global.fields, { ...defaultFields, ...defaultGlobalFields }),
51
+ targets: { ...global.targets, service: { ...service, operations: overDefaults(service?.operations, defaultGlobalOperations) } },
52
+ };
53
+ // `defineKayan`'s own access object, untouched: a rebuilt one would drop the rows
54
+ // marker, and the permission check would stand aside for every global operation.
55
+ return ({ ...kayan(declared), single: true });
56
+ };
43
57
  };
@@ -8,9 +8,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
10
  import { Refusal } from './refusal.js';
11
+ import { flattenMessageFile, nestMessageFile } from './message-file.js';
11
12
  const fileOf = (name, out, directory, root) => out ? new URL(out, root) : new URL(`${name}.json`, directory);
12
13
  // What is on disk wins: these files are the project's to translate, so a run adds a
13
- // missing key and never deletes or rewrites one.
14
+ // missing key and keeps every translation when rewriting the file shape.
14
15
  const merge = (copy, current, locales) => {
15
16
  if (typeof copy === 'function')
16
17
  return null;
@@ -75,7 +76,7 @@ export const generateMessages = async ({ registry, directory, check = false, roo
75
76
  let floor = {};
76
77
  for (const [at, scope] of registry.scopes.entries()) {
77
78
  const file = files[at];
78
- const current = committed[at];
79
+ const current = flattenMessageFile({ file: committed[at], locales: registry.locales });
79
80
  const wanted = { ...current };
80
81
  for (const key of Object.keys(current)) {
81
82
  if (!Object.hasOwn(scope.keys, key))
@@ -108,8 +109,8 @@ export const generateMessages = async ({ registry, directory, check = false, roo
108
109
  report.missing.push(`${scope.name}.${key} has no "${locale}".`);
109
110
  }
110
111
  }
111
- const source = `${JSON.stringify(Object.fromEntries(Object.keys(wanted).sort().map((key) => [key, wanted[key]])), null, 2)}\n`;
112
- if (source === `${JSON.stringify(current, null, 2)}\n`)
112
+ const source = `${JSON.stringify(nestMessageFile(wanted), null, 2)}\n`;
113
+ if (source === `${JSON.stringify(committed[at], null, 2)}\n`)
113
114
  continue;
114
115
  report.changed.push(scope.out ?? `${scope.name}.json`);
115
116
  if (check)
@@ -7,6 +7,7 @@ import type { VersionsTarget } from '../versions/index.ts';
7
7
  import type { StorageTarget } from '../storage/storage.ts';
8
8
  import { buildAsas, type HttpPlug } from '../asas/build.ts';
9
9
  import type { AuthConfig } from '../asas/build.ts';
10
+ import type { DeclarationStore } from '../asas/runner/write.ts';
10
11
  import type { AuditHandler, Derives, DerivedShapes } from '../asas/runner/steps.ts';
11
12
  import type { Where } from './where.ts';
12
13
  import { defineGlobal, defineKayan } from './define.ts';
@@ -100,12 +101,16 @@ export type TenantTarget<RelationTo extends string = string> = {
100
101
  readonly capability: 'tenant';
101
102
  readonly relationTo: RelationTo;
102
103
  readonly onDelete: 'restrict';
104
+ /** A new row on a tenant-owned kayan takes the active organization, through an operation or a step's insert/upsert; naming another organization is refused. */
105
+ readonly newRows: 'active';
106
+ /** How many organizations the project holds; in `'one'` a `connect()` over more refuses to start. */
107
+ readonly organizations: 'one' | 'many';
103
108
  readonly field: TenantField<RelationTo>;
104
109
  /**
105
- * Whether this actor is platform staff in the trusted platform app, who may read every
110
+ * Whether this actor is backoffice staff in the trusted backoffice app, who may read every
106
111
  * tenant's rows with `tenant: 'all'` and never write through it. Absent, no one may.
107
112
  */
108
- readonly platform?: (args: {
113
+ readonly backoffice?: (args: {
109
114
  user: unknown;
110
115
  }) => boolean | Promise<boolean>;
111
116
  };
@@ -136,6 +141,15 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
136
141
  kayan: string;
137
142
  data: Record<string, unknown>;
138
143
  }): Promise<Row>;
144
+ /** Insert or update by the declared unique index `on` names, or by `on: ['id']` without a declared unique index, with key values in `data`. An increment inserts its amount or adds it to the existing value. Returns the row with its existing identity kept. */
145
+ upsert(args: {
146
+ tx: Tx;
147
+ kayan: string;
148
+ on: readonly string[];
149
+ data: Record<string, unknown>;
150
+ where?: Where;
151
+ }): Promise<Row>;
152
+ /** `{ field: { $inc: n } }` adds a safe integer in the database to an integer field, alongside plain values. Store writes skip validation; enforce quantities that must not become negative with a database CHECK. Returns the changed row. */
139
153
  update(args: {
140
154
  tx: Tx;
141
155
  kayan: string;
@@ -154,8 +168,14 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
154
168
  kayan: string;
155
169
  args: {
156
170
  limit: number;
157
- /** A store must hold the rows it returns until the transaction ends: a step that compares a row, such as `expectedUpdatedAt`, relies on it. */
158
- lock?: 'update';
171
+ /**
172
+ * Hold returned rows until this transaction ends. `update` excludes every competing lock;
173
+ * `noKeyUpdate` keeps their values unchanged by others while allowing `keyShare` readers;
174
+ * `share` keeps them as read while allowing other `share` and `keyShare` readers;
175
+ * `keyShare` keeps their identity and unique keys, allowing other values to change.
176
+ * Every mode requires a transaction; a lock without one must throw.
177
+ */
178
+ lock?: 'update' | 'noKeyUpdate' | 'share' | 'keyShare';
159
179
  page: number;
160
180
  sort: {
161
181
  field: string;
@@ -168,6 +188,13 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
168
188
  docs: Row[];
169
189
  totalDocs: number;
170
190
  }>;
191
+ /** Exact total of an integer, number or decimal field, as a decimal string so no value passes through a float; empty selections return '0'. */
192
+ sum(args: {
193
+ tx?: Tx;
194
+ kayan: string;
195
+ field: string;
196
+ where?: Where;
197
+ }): Promise<string>;
171
198
  /** Counts matching rows without loading them. Without it, `find` with a limit of `0` is asked. */
172
199
  count?(args: {
173
200
  kayan: string;
@@ -178,6 +205,18 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
178
205
  }): Promise<number>;
179
206
  destroy(): Promise<void>;
180
207
  };
208
+ /** The runner keeps bound string-name calls behind the declaration boundary: access and field masks are skipped, tenant scope is not. Upsert fills the tenant from the active organization and refuses another value. Never expose privileged rows or fields through step output. */
209
+ export type RawStepStore = Pick<Store, 'find' | 'insert' | 'remove' | 'update' | 'upsert' | 'sum'> & {
210
+ /** Holds existing ids in ascending order until the transaction ends, deduplicated; any missing or foreign id is a 404. Requires a transaction. */
211
+ hold(args: {
212
+ tx: unknown;
213
+ kayan: string;
214
+ ids: readonly unknown[];
215
+ mode: NonNullable<Parameters<Store['find']>[0]['args']['lock']>;
216
+ }): Promise<void>;
217
+ };
218
+ /** A step names a kayan by its declaration; fields and values follow that declaration, as with tools.of. */
219
+ export type StepStore = DeclarationStore;
181
220
  /** A target that holds rows. Takes `undefined`, so a caller can name the bad target in its own throw. */
182
221
  export declare const isStore: (target: RuntimeTarget | undefined) => target is RuntimeStore;
183
222
  /** A target that says who may do what. */
@@ -0,0 +1,13 @@
1
+ /** A translated leaf, an opaque resolver result, or a function's unverifiable marker. */
2
+ export type MessageEntry = Record<string, string> | string | null;
3
+ type Branch = {
4
+ [key: string]: MessageEntry | Branch;
5
+ };
6
+ /** Reads both release formats, keeping a parent's own value separate from its children. */
7
+ export declare const flattenMessageFile: ({ file, locales }: {
8
+ file: Record<string, unknown>;
9
+ locales: readonly string[];
10
+ }) => Record<string, MessageEntry>;
11
+ /** A value gets `$value` only when another key needs to pass through it. */
12
+ export declare const nestMessageFile: (entries: Record<string, MessageEntry>) => Branch;
13
+ export {};
@@ -0,0 +1,29 @@
1
+ const branch = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ /** Reads both release formats, keeping a parent's own value separate from its children. */
3
+ export const flattenMessageFile = ({ file, locales }) => {
4
+ const entries = [];
5
+ const walk = (node, parts) => {
6
+ for (const [name, value] of Object.entries(node)) {
7
+ const path = [...parts, name];
8
+ if (name === '$value')
9
+ entries.push([parts.join('.'), value]);
10
+ else if (branch(value) && !(Object.values(value).every((one) => typeof one === 'string') && (!Object.keys(value).length || Object.keys(value).some((key) => locales.includes(key)))))
11
+ walk(value, path);
12
+ else
13
+ entries.push([path.join('.'), value]);
14
+ }
15
+ };
16
+ walk(file, []);
17
+ return Object.fromEntries(entries);
18
+ };
19
+ /** A value gets `$value` only when another key needs to pass through it. */
20
+ export const nestMessageFile = (entries) => {
21
+ const result = Object.create(null);
22
+ const keys = Object.keys(entries).sort();
23
+ for (const key of keys) {
24
+ const parts = [...key.split('.'), ...(keys.some((other) => other.startsWith(`${key}.`)) ? ['$value'] : [])];
25
+ const last = parts.pop();
26
+ parts.reduce((node, part) => (node[part] ??= Object.create(null)), result)[last] = entries[key];
27
+ }
28
+ return result;
29
+ };
@@ -42,6 +42,11 @@ export const messagesOf = ({ language, kayanList, resolver, }) => {
42
42
  continue;
43
43
  claim(name, `field "${name}" label`, field.label);
44
44
  }
45
+ // Screens share this fallback key, so the first description is claimed once.
46
+ const describe = (screen) => kayan.targets?.[screen]?.description;
47
+ const described = Object.keys(language.targets).find((screen) => language.targets[screen].capability === 'screen' && describe(screen));
48
+ if (described)
49
+ claim('page.description', `screen "${described}" description`, describe(described));
45
50
  // Claimed once per field however many screens ask for it.
46
51
  for (const [name, operation] of Object.entries(kayan.operations)) {
47
52
  const fields = Object.entries(operation.targets ?? {}).flatMap(([screen, block]) => language.targets[screen]?.capability === 'screen' ? Object.entries(block?.fields ?? {}) : []);
@@ -1,6 +1,7 @@
1
1
  import { admissions, rowsOf, rowsOfField } from './rows.js';
2
2
  import { isRef, refuseLiteral } from './user.js';
3
- import { bulkSeals, defaultGlobalOperations, holdOutput, readSeals, resolveOperations, sealOf, storeSeals, versionSeals, } from '../asas/runner/steps.js';
3
+ import { jsonSchemaCheck } from '../base/json-schema.js';
4
+ import { bulkSeals, defaultGlobalOperations, holdOutput, readSeals, resolveOperations, sealOf, foreignCopyOf, storeSeals, versionSeals, } from '../asas/runner/steps.js';
4
5
  import { kayanServiceKeys, unsortable } from './types.js';
5
6
  import { compareDecimals } from '../base/decimal.js';
6
7
  import { isAccess, isScreen, isService, isStorage, isStore, isTenant, isVersions, serviceTargetOf, tenantDefaults } from './language.js';
@@ -17,7 +18,7 @@ export const versionsTableOf = (kayan) => `_${kayan.table}_versions`;
17
18
  /** The default indexes a `unique` rule makes, which a store may build as the column's own constraint. */
18
19
  export const uniqueRuleIndexes = new WeakSet();
19
20
  export const isPlainIdentifier = (value) => value.length <= 63 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
20
- const fixed = new Set(['access', 'column', 'custom', 'dbName', 'description', 'kayan', 'kind', 'kindTargets', 'label', 'locales', 'name', 'now', 'operators', 'options', 'placeholder', 'prefix', 'schemaTarget', 'sortable', 'suffix', 'target', 'targets', 'rules']);
21
+ const fixed = new Set(['access', 'column', 'custom', 'dbName', 'description', 'jsonSchema', 'kayan', 'kind', 'kindTargets', 'label', 'locales', 'maxBytes', 'name', 'now', 'operators', 'options', 'placeholder', 'prefix', 'schemaTarget', 'sortable', 'suffix', 'target', 'targets', 'rules']);
21
22
  // Every list holds exactly one store or managed read step, and none of the three
22
23
  // the runner owns; the effect is read off that one step, so it is never declared.
23
24
  const effects = {
@@ -102,6 +103,11 @@ const sealed = (kayan, operation, declaration, service) => {
102
103
  if (list.some((step) => typeof step !== 'function')) {
103
104
  throw new Error(`${where} has a step that is not a function.`);
104
105
  }
106
+ // Its errors, actors and secret columns are unknown here too, so two copies are refused, never bridged.
107
+ const foreign = list.filter((step) => !sealOf(step)).map(foreignCopyOf).find(Boolean);
108
+ if (foreign) {
109
+ throw new Error(`${where} holds a step another copy of mithaq sealed, loaded from ${foreign}, beside this one at ${import.meta.url}. Install one copy: name mithaq by the same spec, with the same peers, in every package that imports it, so the package manager links it once.`);
110
+ }
105
111
  const held = list.map(sealOf);
106
112
  for (const seal of held) {
107
113
  const owned = seal && runnerSteps[seal];
@@ -134,7 +140,7 @@ const sealed = (kayan, operation, declaration, service) => {
134
140
  return resolved({ effect });
135
141
  };
136
142
  // `onDelete` is a rule too, so on a relationship it is still checked against every target.
137
- const relationshipKeys = new Set(['hasMany', 'mode', 'relationTo']);
143
+ const relationshipKeys = new Set(['filterOptions', 'hasMany', 'mode', 'relationTo']);
138
144
  const joinKeys = new Set(['defaultLimit', 'defaultSort', 'on', 'relationTo']);
139
145
  /** Names that reach the prototype rather than a key an actor carries. */
140
146
  const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']);
@@ -144,7 +150,7 @@ const targetOf = (who, named, language, role) => {
144
150
  const target = language.targets[named];
145
151
  if (!target || isStore(target) !== (role === 'store')) {
146
152
  throw new Error(role === 'store'
147
- ? `${who} names store "${named}", which is not a store: a store target declares capability "store" and has init, connect, transaction, insert, update, remove, find and destroy beside kinds and rules.`
153
+ ? `${who} names store "${named}", which is not a store: a store target declares capability "store" and has init, connect, transaction, insert, update, upsert, remove, find, sum and destroy beside kinds and rules.`
148
154
  : `${who} names schema "${named}", which is not a schema target: a schema target has kinds and rules, and does not declare capability "store".`);
149
155
  }
150
156
  if (role === 'schema' && typeof target.derive !== 'function') {
@@ -183,7 +189,7 @@ const nameOf = (relationTo) => typeof relationTo === 'function' ? String(relatio
183
189
  const tenantField = ({ where, field, language }) => {
184
190
  const target = language.targets.tenant;
185
191
  if (!isTenant(target)) {
186
- throw new Error(`${where} is bound through targets.tenant, and this language lays down no tenant target. Add tenant: betterAuthTenant({ relationTo, onDelete: 'restrict' }) to its targets, or drop targets.tenant from the field.`);
192
+ throw new Error(`${where} is bound through targets.tenant, and this language lays down no tenant target. Add tenant: betterAuthTenant({ relationTo, onDelete: 'restrict', newRows: 'active', organizations }) to its targets, or drop targets.tenant from the field.`);
187
193
  }
188
194
  const bound = tenantDefaults(language, field);
189
195
  const rules = bound.rules ?? {};
@@ -212,6 +218,10 @@ const tenantField = ({ where, field, language }) => {
212
218
  }
213
219
  return bound;
214
220
  };
221
+ const messageName = (where, name) => {
222
+ if (name.startsWith('$'))
223
+ throw new Error(`${where} "${name}" is reserved: names starting with "$" are reserved for message-file values.`);
224
+ };
215
225
  // A key written flat is refused with the nesting spelled out. A node holding both a
216
226
  // sentence and a group, or nothing, is refused: either reading would destroy copy.
217
227
  const flattenMessages = (where, messages, locales, parts = []) =>
@@ -219,6 +229,8 @@ const flattenMessages = (where, messages, locales, parts = []) =>
219
229
  // runs the setter, so the copy would vanish and the prototype be whatever was written.
220
230
  Object.fromEntries(Object.entries(messages).flatMap(([key, value]) => {
221
231
  const path = [...parts, ...key.split('.')];
232
+ if (key.startsWith('$'))
233
+ throw new Error(`Kayan "${where}" message key "${path.join('.')}" is reserved: names starting with "$" are reserved for message-file values.`);
222
234
  if (key.includes('.')) {
223
235
  throw new Error(`Kayan "${where}" writes the message key "${path.join('.')}" flat. Nest it under "${key.split('.')[0]}": messages: ${path.map((part) => `{ ${part}: `).join('')}...${' }'.repeat(path.length)}.`);
224
236
  }
@@ -295,12 +307,14 @@ export const resolve = (kayanList, language) => {
295
307
  // For a table written as plain data, which `kind` does not type. A rule may ship no
296
308
  // sentence; every kind refuses the value it is handed.
297
309
  for (const [name, kind] of Object.entries(language.kinds)) {
310
+ messageName("The language's kind", name);
298
311
  if (!kind.message) {
299
312
  throw new Error(`The language's kind "${name}" declares no message, so a value it refuses has no sentence to show.`);
300
313
  }
301
314
  }
302
315
  // A typo would otherwise exclude nothing in silence.
303
316
  for (const [name, one] of Object.entries(language.rules)) {
317
+ messageName("The language's rule", name);
304
318
  const missing = one.excludes?.find((other) => !Object.hasOwn(language.rules, other));
305
319
  if (missing) {
306
320
  throw new Error(`The language's rule "${name}" excludes "${missing}", which is not a rule the language names.`);
@@ -466,6 +480,9 @@ export const resolve = (kayanList, language) => {
466
480
  if (isTenant(tenantTarget) && tenantTarget.onDelete !== 'restrict') {
467
481
  throw new Error(`The tenant target declares onDelete ${JSON.stringify(tenantTarget.onDelete)}, and a tenant takes onDelete: 'restrict' alone.`);
468
482
  }
483
+ if (isTenant(tenantTarget) && tenantTarget.newRows !== 'active') {
484
+ throw new Error(`The tenant target declares newRows ${JSON.stringify(tenantTarget.newRows)}, and a tenant takes newRows: 'active' alone.`);
485
+ }
469
486
  const byName = new Map(kayanList.map((kayan) => [kayan.name, kayan]));
470
487
  const resolved = kayanList.map((kayan) => {
471
488
  if (kayan.targets && Object.hasOwn(kayan.targets, 'tenant')) {
@@ -500,6 +517,9 @@ export const resolve = (kayanList, language) => {
500
517
  continue;
501
518
  throw new Error(`Kayan "${kayan.name}" writes "targets.service.${key}", which is no key of a kayan's service target. A kayan writes ${kayanServiceKeys.join(', ')} there.`);
502
519
  }
520
+ if (typeof own.operations === 'function') {
521
+ throw new Error(`Kayan "${kayan.name}" writes its operations as a function, which only defineKayan and defineGlobal run, since they know the defaults it is handed. Declare it with defineKayan, or write its operations as an object: { ...defaultOperations, archive }.`);
522
+ }
503
523
  return {
504
524
  ...kayan,
505
525
  // Flattened here, so every reader of a resolved kayan sees the key space a generated file carries.
@@ -513,6 +533,7 @@ export const resolve = (kayanList, language) => {
513
533
  // Run again for a kayan written as plain data. `false` would remove nothing from
514
534
  // the kayan's own record, so it is refused by name.
515
535
  operations: Object.fromEntries(Object.entries(resolveOperations(own.operations, service)).filter(([operation, declaration]) => {
536
+ messageName(`Kayan "${kayan.name}" operation`, operation);
516
537
  if (declaration === false) {
517
538
  throw new Error(`Kayan "${kayan.name}" operation "${operation}" is false, and false is no operation. Leave "${operation}" out of the object instead: const { ${operation}, ...rest } = defaultOperations.`);
518
539
  }
@@ -533,10 +554,9 @@ export const resolve = (kayanList, language) => {
533
554
  required: field.rules?.required ?? false,
534
555
  // A join holds no column here, so `providedBy: never` keeps the key off every write shape.
535
556
  ...(field.kind === 'join' && { providedBy: { create: 'never', update: 'never' } }),
536
- // A `hasMany` field holds no column on this table; its join rows go with the parent by cascade.
537
- ...(pointsAtRow(field.kind) && field.relationTo && !field.hasMany && {
538
- // A required field restricts, since `set null` would break it; any other takes its kind's default.
539
- onDelete: field.rules?.onDelete ?? (field.rules?.required ? 'restrict' : language.kinds[field.kind].onDelete),
557
+ // A required field and a set restrict, since neither has a null to set; any other takes its kind's default.
558
+ ...(pointsAtRow(field.kind) && field.relationTo && {
559
+ onDelete: field.rules?.onDelete ?? (field.rules?.required || field.hasMany ? 'restrict' : language.kinds[field.kind].onDelete),
540
560
  }),
541
561
  },
542
562
  // A join builds no column, but takes a name so the one place that prints it prints no blank.
@@ -593,6 +613,35 @@ export const resolve = (kayanList, language) => {
593
613
  }
594
614
  }
595
615
  };
616
+ const jsonSchemaNamed = (where, field) => {
617
+ if (field.kind !== 'json' && field.maxBytes !== undefined)
618
+ throw new Error(`${where} has a maxBytes, and kind "${field.kind}" takes none; only a json field does.`);
619
+ if (field.kind === 'json' && !(Number.isInteger(field.maxBytes) && field.maxBytes > 0)) {
620
+ throw new Error(`${where} is json and names no maxBytes; name maxBytes: defaultJsonMaxBytes, or a whole number of its own.`);
621
+ }
622
+ const { jsonSchema } = field;
623
+ if (jsonSchema === undefined)
624
+ return;
625
+ if (field.kind !== 'json')
626
+ throw new Error(`${where} has a jsonSchema, and kind "${field.kind}" takes none; only a json field does.`);
627
+ const schema = jsonSchema?.schema;
628
+ if (typeof schema !== 'boolean' && (!schema || typeof schema !== 'object' || Array.isArray(schema))) {
629
+ throw new Error(`${where} jsonSchema names no "schema"; a schema is held in the field and never fetched from its "uri".`);
630
+ }
631
+ const { uri, fileMatch } = jsonSchema;
632
+ if ((uri !== undefined && typeof uri !== 'string') || (fileMatch !== undefined && !(Array.isArray(fileMatch) && fileMatch.every((one) => typeof one === 'string')))) {
633
+ throw new Error(`${where} jsonSchema "fileMatch" is a list of strings and "uri" a string.`);
634
+ }
635
+ };
636
+ // Else every create that leaves the field out would be refused for a value it never sent.
637
+ const jsonDefaultNamed = (where, field) => {
638
+ const value = field.rules?.defaultValue;
639
+ if (field.kind !== 'json' || value === undefined || isRef(value))
640
+ return;
641
+ if (new TextEncoder().encode(JSON.stringify(value)).length > field.maxBytes || (field.jsonSchema && !jsonSchemaCheck(field)(value))) {
642
+ throw new Error(`${where} defaultValue is refused by its own jsonSchema or maxBytes.`);
643
+ }
644
+ };
596
645
  const declaredTargets = (where, wrote, known = targetNames) => {
597
646
  for (const name of Object.keys(wrote ?? {})) {
598
647
  if (known.has(name))
@@ -609,6 +658,7 @@ export const resolve = (kayanList, language) => {
609
658
  targetOf(`Kayan "${kayan.name}"`, kayan.schema, language, 'schema');
610
659
  for (const field of Object.values(kayan.fields)) {
611
660
  const where = `Kayan "${kayan.name}" field "${field.name}"`;
661
+ messageName(`Kayan "${kayan.name}" field`, field.name);
612
662
  declaredTargets(where, field.targets);
613
663
  // The field found must be a relationship pointing back here, or the join would read
614
664
  // a column that is no pointer back and answer another row's rows.
@@ -674,10 +724,17 @@ export const resolve = (kayanList, language) => {
674
724
  if (Object.hasOwn(field, 'mode')) {
675
725
  throw new Error(`${where} carries "mode", which a field does not take: a relationship points at one row, and "hasMany: true" makes it hold a set.`);
676
726
  }
727
+ // Its delete would fail in the database on the not-null column instead.
728
+ if (!field.hasMany && field.rules?.required && field.rules.onDelete === 'set null') {
729
+ throw new Error(`${where} is required and declares onDelete "set null", and a required column holds no null. Write "restrict" or "cascade".`);
730
+ }
677
731
  // A rule that silently builds nothing is what this file exists to prevent.
678
732
  // `required` is not one: the schema target reads it off the array.
679
733
  if (field.hasMany) {
680
- const columnRule = ['defaultValue', 'index', 'onDelete', 'unique'].find((rule) => field.rules?.[rule] !== undefined);
734
+ if (field.rules?.onDelete === 'set null') {
735
+ throw new Error(`${where} is a relationship with hasMany and onDelete "set null", and a set's join row holds no null. Write "restrict", which keeps the row while a set names it, or "cascade", which takes it out of every set.`);
736
+ }
737
+ const columnRule = ['defaultValue', 'index', 'unique'].find((rule) => field.rules?.[rule] !== undefined);
681
738
  if (columnRule) {
682
739
  throw new Error(`${where} is a relationship with hasMany, which the store holds as a join table and not as a column of this kayan, so rule "${columnRule}" would build nothing. Declare it on the kayan this field points at, or drop it.`);
683
740
  }
@@ -694,6 +751,8 @@ export const resolve = (kayanList, language) => {
694
751
  }
695
752
  optionsNamed(where, field.kind, field);
696
753
  affixesNamed(where, field.kind, field);
754
+ jsonSchemaNamed(where, field);
755
+ jsonDefaultNamed(where, field);
697
756
  for (const [screen, block] of Object.entries(field.targets ?? {})) {
698
757
  if (!isScreen(language.targets[screen]))
699
758
  continue;
@@ -849,6 +908,7 @@ export const resolve = (kayanList, language) => {
849
908
  const inputs = Object.entries(declaration.targets ?? {}).flatMap(([screen, block]) => isScreen(language.targets[screen]) ? Object.entries(block.fields ?? {}) : []);
850
909
  for (const [name, field] of inputs) {
851
910
  const where = `Kayan "${kayan.name}" operation "${operation}" field "${name}"`;
911
+ messageName(`Kayan "${kayan.name}" operation "${operation}" field`, name);
852
912
  declaredTargets(where, field.targets);
853
913
  optionsNamed(where, field.kind, field);
854
914
  affixesNamed(where, field.kind, field);