@drhomet/mithaq 0.0.0-stage → 0.1.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 (151) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/payload.txt +22 -0
  3. package/LICENSES/ponytail.txt +21 -0
  4. package/dist/asas/actor.d.ts +96 -0
  5. package/dist/asas/actor.js +114 -0
  6. package/dist/asas/build.d.ts +231 -0
  7. package/dist/asas/build.js +787 -0
  8. package/dist/asas/cli/mithaq.d.ts +2 -0
  9. package/dist/asas/cli/mithaq.js +145 -0
  10. package/dist/asas/cli/upgrade.d.ts +20 -0
  11. package/dist/asas/cli/upgrade.js +147 -0
  12. package/dist/asas/http/fetch.d.ts +23 -0
  13. package/dist/asas/http/fetch.js +115 -0
  14. package/dist/asas/http/orpc.d.ts +43 -0
  15. package/dist/asas/http/orpc.js +135 -0
  16. package/dist/asas/runner/bulk.d.ts +20 -0
  17. package/dist/asas/runner/bulk.js +191 -0
  18. package/dist/asas/runner/errors.d.ts +60 -0
  19. package/dist/asas/runner/errors.js +59 -0
  20. package/dist/asas/runner/read.d.ts +68 -0
  21. package/dist/asas/runner/read.js +579 -0
  22. package/dist/asas/runner/runtime.d.ts +21 -0
  23. package/dist/asas/runner/runtime.js +8 -0
  24. package/dist/asas/runner/steps.d.ts +400 -0
  25. package/dist/asas/runner/steps.js +271 -0
  26. package/dist/asas/runner/tenant.d.ts +54 -0
  27. package/dist/asas/runner/tenant.js +213 -0
  28. package/dist/asas/runner/write.d.ts +444 -0
  29. package/dist/asas/runner/write.js +791 -0
  30. package/dist/auth/better-auth/access.d.ts +15 -0
  31. package/dist/auth/better-auth/access.js +71 -0
  32. package/dist/auth/better-auth/generate.d.ts +29 -0
  33. package/dist/auth/better-auth/generate.js +310 -0
  34. package/dist/auth/better-auth/index.d.ts +92 -0
  35. package/dist/auth/better-auth/index.js +81 -0
  36. package/dist/auth/better-auth/tenant.d.ts +18 -0
  37. package/dist/auth/better-auth/tenant.js +26 -0
  38. package/dist/auth/local/index.d.ts +285 -0
  39. package/dist/auth/local/index.js +736 -0
  40. package/dist/base/decimal.d.ts +9 -0
  41. package/dist/base/decimal.js +16 -0
  42. package/dist/base/fields.d.ts +123 -0
  43. package/dist/base/fields.js +51 -0
  44. package/dist/base/index.d.ts +46 -0
  45. package/dist/base/index.js +5 -0
  46. package/dist/base/kinds.d.ts +54 -0
  47. package/dist/base/kinds.js +78 -0
  48. package/dist/base/rules.d.ts +73 -0
  49. package/dist/base/rules.js +80 -0
  50. package/dist/drizzle/index.d.ts +438 -0
  51. package/dist/drizzle/index.js +832 -0
  52. package/dist/drizzle/kinds.d.ts +27 -0
  53. package/dist/drizzle/kinds.js +41 -0
  54. package/dist/drizzle/migrate.d.ts +133 -0
  55. package/dist/drizzle/migrate.js +1285 -0
  56. package/dist/drizzle/proxy/index.d.ts +1 -0
  57. package/dist/drizzle/proxy/index.js +1 -0
  58. package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
  59. package/dist/drizzle/proxy/node-postgres.js +1 -0
  60. package/dist/drizzle/proxy/pg-core.d.ts +1 -0
  61. package/dist/drizzle/proxy/pg-core.js +1 -0
  62. package/dist/drizzle/rules.d.ts +336 -0
  63. package/dist/drizzle/rules.js +44 -0
  64. package/dist/index.d.ts +45 -0
  65. package/dist/index.js +36 -0
  66. package/dist/mithaq/define.d.ts +288 -0
  67. package/dist/mithaq/define.js +43 -0
  68. package/dist/mithaq/generate-messages.d.ts +38 -0
  69. package/dist/mithaq/generate-messages.js +234 -0
  70. package/dist/mithaq/language.d.ts +335 -0
  71. package/dist/mithaq/language.js +76 -0
  72. package/dist/mithaq/lock.d.ts +71 -0
  73. package/dist/mithaq/lock.js +329 -0
  74. package/dist/mithaq/messages.d.ts +55 -0
  75. package/dist/mithaq/messages.js +106 -0
  76. package/dist/mithaq/refusal.d.ts +6 -0
  77. package/dist/mithaq/refusal.js +6 -0
  78. package/dist/mithaq/resolve.d.ts +10 -0
  79. package/dist/mithaq/resolve.js +954 -0
  80. package/dist/mithaq/rows.d.ts +87 -0
  81. package/dist/mithaq/rows.js +174 -0
  82. package/dist/mithaq/rukn.d.ts +72 -0
  83. package/dist/mithaq/rukn.js +15 -0
  84. package/dist/mithaq/standard-schema.d.ts +38 -0
  85. package/dist/mithaq/standard-schema.js +2 -0
  86. package/dist/mithaq/types.d.ts +358 -0
  87. package/dist/mithaq/types.js +15 -0
  88. package/dist/mithaq/user.d.ts +19 -0
  89. package/dist/mithaq/user.js +16 -0
  90. package/dist/mithaq/where.d.ts +32 -0
  91. package/dist/mithaq/where.js +87 -0
  92. package/dist/rbac/index.d.ts +105 -0
  93. package/dist/rbac/index.js +182 -0
  94. package/dist/rbac/steps.d.ts +22 -0
  95. package/dist/rbac/steps.js +161 -0
  96. package/dist/rich-text/index.d.ts +27 -0
  97. package/dist/rich-text/index.js +58 -0
  98. package/dist/rich-text/rich-text.d.ts +159 -0
  99. package/dist/rich-text/rich-text.js +372 -0
  100. package/dist/screen/client.d.ts +153 -0
  101. package/dist/screen/client.js +68 -0
  102. package/dist/screen/generate.d.ts +14 -0
  103. package/dist/screen/generate.js +226 -0
  104. package/dist/screen/index.d.ts +479 -0
  105. package/dist/screen/index.js +645 -0
  106. package/dist/screen/react.d.ts +95 -0
  107. package/dist/screen/react.js +58 -0
  108. package/dist/screen/studio.d.ts +24 -0
  109. package/dist/screen/studio.js +68 -0
  110. package/dist/service/index.d.ts +63 -0
  111. package/dist/service/index.js +15 -0
  112. package/dist/storage/allowed-type.d.ts +5 -0
  113. package/dist/storage/allowed-type.js +7 -0
  114. package/dist/storage/index.d.ts +43 -0
  115. package/dist/storage/index.js +44 -0
  116. package/dist/storage/storage.d.ts +384 -0
  117. package/dist/storage/storage.js +405 -0
  118. package/dist/versions/index.d.ts +37 -0
  119. package/dist/versions/index.js +8 -0
  120. package/dist/versions/versions.d.ts +104 -0
  121. package/dist/versions/versions.js +160 -0
  122. package/dist/zod/index.d.ts +184 -0
  123. package/dist/zod/index.js +121 -0
  124. package/dist/zod/kinds.d.ts +34 -0
  125. package/dist/zod/kinds.js +49 -0
  126. package/dist/zod/rules.d.ts +117 -0
  127. package/dist/zod/rules.js +67 -0
  128. package/package.json +63 -4
  129. package/r/mithaq-cells.json +20 -0
  130. package/r/mithaq-controls.json +37 -0
  131. package/r/mithaq-directional-icon.json +15 -0
  132. package/r/mithaq-expected-updated-at.json +15 -0
  133. package/r/mithaq-field-shell.json +18 -0
  134. package/r/mithaq-filter-operators.json +15 -0
  135. package/r/mithaq-form.json +33 -0
  136. package/r/mithaq-list-columns.json +23 -0
  137. package/r/mithaq-list-filters.json +24 -0
  138. package/r/mithaq-list-pagination.json +24 -0
  139. package/r/mithaq-list-query.json +18 -0
  140. package/r/mithaq-list-selection.json +19 -0
  141. package/r/mithaq-list.json +33 -0
  142. package/r/mithaq-nav.json +24 -0
  143. package/r/mithaq-nodes.json +34 -0
  144. package/r/mithaq-relationship-cell.json +20 -0
  145. package/r/mithaq-relationship-control.json +20 -0
  146. package/r/mithaq-rich-text.json +40 -0
  147. package/r/mithaq-screens-host.json +96 -0
  148. package/r/mithaq-storage.json +44 -0
  149. package/r/mithaq-upload.json +29 -0
  150. package/r/registry.json +550 -0
  151. package/README.md +0 -3
@@ -0,0 +1,954 @@
1
+ import { admissions, rowsOf, rowsOfField } from './rows.js';
2
+ import { isRef, refuseLiteral } from './user.js';
3
+ import { bulkSeals, defaultGlobalOperations, holdOutput, readSeals, resolveOperations, sealOf, storeSeals, versionSeals, } from '../asas/runner/steps.js';
4
+ import { kayanServiceKeys, unsortable } from './types.js';
5
+ import { compareDecimals } from '../base/decimal.js';
6
+ import { isAccess, isScreen, isService, isStorage, isStore, isTenant, isVersions, serviceTargetOf, tenantDefaults } from './language.js';
7
+ import { versionsOf } from '../versions/versions.js';
8
+ import { providerOf } from '../base/rules.js';
9
+ import { implementationOf } from './rukn.js';
10
+ // A capital opens a word, except at the start, where there is nothing to break.
11
+ export const snakeCase = (name) => name.replace(/[A-Z]/g, (letter, at) => (at === 0 ? '' : '_') + letter.toLowerCase());
12
+ // The same string is its SQL name and its key on the store's tables, so the push
13
+ // and `mithaq migrate` reach it unchanged.
14
+ export const versionsTableOf = (kayan) => `_${kayan.table}_versions`;
15
+ // drizzle-kit reads every table name as a minimatch glob, so only a plain
16
+ // identifier of at most 63 bytes, the length Postgres keeps, is safe.
17
+ /** The default indexes a `unique` rule makes, which a store may build as the column's own constraint. */
18
+ export const uniqueRuleIndexes = new WeakSet();
19
+ 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
+ // Every list holds exactly one store or managed read step, and none of the three
22
+ // the runner owns; the effect is read off that one step, so it is never declared.
23
+ const effects = {
24
+ insert: 'create',
25
+ update: 'update',
26
+ remove: 'delete',
27
+ count: 'read',
28
+ find: 'read',
29
+ findOne: 'read',
30
+ // A bulk list has the effect of the single-row write it runs per row.
31
+ updateMany: 'update',
32
+ deleteMany: 'delete',
33
+ findVersions: 'read',
34
+ findVersionByID: 'read',
35
+ restoreVersion: 'update',
36
+ };
37
+ // A Map, not a record: a kayan named `constructor` would answer an `in` check on a
38
+ // record. Refused before resolving, whatever the kayan declares.
39
+ const reservedKayan = new Map([
40
+ [
41
+ // `asas.handler` answers `/api/auth/<provider>` before the router, so this kayan's
42
+ // operations would sit under a taken prefix.
43
+ 'auth',
44
+ "asas.handler routes /api/auth to the auth providers before the router, so this kayan's operations would never be reached.",
45
+ ],
46
+ ['mithaq', '`mithaq generate messages` writes a scope of that name and finds it by name, so this kayan would shadow it.'],
47
+ ['project', '`mithaq generate messages` writes a scope of that name and finds it by name, so this kayan would shadow it.'],
48
+ ['screen', 'the catalog derives screen.<name> for every screen, so this kayan\'s keys would be read as screens.'],
49
+ ]);
50
+ // In the sealed record at runtime, so one written anyway is refused by name rather
51
+ // than reaching a list as `undefined`.
52
+ const runnerSteps = {
53
+ defaults: 'the runner fills every default it would have filled, before the list runs',
54
+ validate: 'the runner parses this operation\'s shape before the list runs, and the shape is the operation\'s own "input" key',
55
+ audit: 'audit is a step of the service target\'s "afterSteps"',
56
+ };
57
+ // `beforeSteps` is the service target's alone: a project-wide precondition is no operation's to restate.
58
+ const slotsOf = (where, declaration, service, read) => {
59
+ const own = declaration;
60
+ const resolved = {
61
+ beforeTransaction: own.beforeTransaction ?? [],
62
+ beforeSteps: read ? [] : service.beforeSteps ?? [],
63
+ afterSteps: own.afterSteps ?? (read ? [] : service.afterSteps ?? []),
64
+ afterCommit: own.afterCommit ?? (read ? [] : service.afterCommit ?? []),
65
+ };
66
+ for (const slot of ['beforeTransaction', 'afterCommit']) {
67
+ if (read && own[slot]) {
68
+ throw new Error(`${where} declares "${slot}", and a read opens no transaction, so there is no commit for it to sit outside of.`);
69
+ }
70
+ }
71
+ // Refused rather than dropped: the list would build and never run.
72
+ if (own.beforeSteps) {
73
+ throw new Error(`${where} declares "beforeSteps", and that list is the service target's alone, because a project-wide precondition is not an operation's to restate.`);
74
+ }
75
+ for (const [slot, list] of Object.entries(resolved)) {
76
+ if (!Array.isArray(list))
77
+ throw new Error(`${where} needs a list of steps in "${slot}", or a function that returns one.`);
78
+ for (const step of list) {
79
+ if (typeof step !== 'function')
80
+ throw new Error(`${where} has a step in "${slot}" that is not a function.`);
81
+ const held = sealOf(step);
82
+ // `audit` only in `afterSteps`, the one slot inside the transaction after the row
83
+ // was written; outside it, it would get no `tx`.
84
+ if (!held || (held === 'audit' && slot === 'afterSteps'))
85
+ continue;
86
+ if (held === 'audit') {
87
+ throw new Error(`${where} writes the "audit" step in "${slot}", and "afterSteps" is audit's slot, where the transaction is open and the row has been written.`);
88
+ }
89
+ const owned = runnerSteps[held];
90
+ throw new Error(owned
91
+ ? `${where} writes the "${held}" step in "${slot}", and ${owned}, so no list names it.`
92
+ : `${where} holds the "${held}" step in "${slot}", and a store or managed read step is what the operation itself does, so it belongs in "steps".`);
93
+ }
94
+ }
95
+ return resolved;
96
+ };
97
+ const sealed = (kayan, operation, declaration, service) => {
98
+ const where = `Kayan "${kayan}" operation "${operation}"`;
99
+ const list = declaration.steps;
100
+ if (!Array.isArray(list))
101
+ throw new Error(`${where} needs a list of steps, or a function that returns one.`);
102
+ if (list.some((step) => typeof step !== 'function')) {
103
+ throw new Error(`${where} has a step that is not a function.`);
104
+ }
105
+ const held = list.map(sealOf);
106
+ for (const seal of held) {
107
+ const owned = seal && runnerSteps[seal];
108
+ if (owned)
109
+ throw new Error(`${where} writes the "${seal}" step, and ${owned}, so a list never names it.`);
110
+ }
111
+ const acts = (seal) => !!seal && (storeSeals.includes(seal) || readSeals.includes(seal) || bulkSeals.includes(seal) || versionSeals.includes(seal));
112
+ const at = held.findIndex(acts);
113
+ const acting = held.filter(acts).length;
114
+ if (acting !== 1) {
115
+ throw new Error(`${where} has ${acting} store or managed read steps, and every operation has exactly one insert, update, remove, updateMany, deleteMany, find, findOne, count, findVersions, findVersionByID or restoreVersion.`);
116
+ }
117
+ const seal = held[at];
118
+ const effect = effects[seal];
119
+ const { output, ...rest } = declaration;
120
+ const slots = slotsOf(where, declaration, service, effect === 'read');
121
+ // The derived shapes do not exist until every kayan is resolved, so `output` may stay a function for `build`.
122
+ const resolved = (read) => {
123
+ const one = { ...rest, ...slots, steps: list, ...read };
124
+ if (typeof output === 'function')
125
+ holdOutput(one, output);
126
+ else if (output)
127
+ one.output = output;
128
+ return one;
129
+ };
130
+ if (effect === 'read') {
131
+ const modes = { findOne: 'one', count: 'count', findVersions: 'versions', findVersionByID: 'version' };
132
+ return resolved({ effect, mode: modes[seal] ?? 'many' });
133
+ }
134
+ return resolved({ effect });
135
+ };
136
+ // `onDelete` is a rule too, so on a relationship it is still checked against every target.
137
+ const relationshipKeys = new Set(['hasMany', 'mode', 'relationTo']);
138
+ const joinKeys = new Set(['defaultLimit', 'defaultSort', 'on', 'relationTo']);
139
+ /** Names that reach the prototype rather than a key an actor carries. */
140
+ const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']);
141
+ // `!target` stays in front: `isStore` answers `false` for a missing target, which
142
+ // would equal the schema half's `false` and pass a name no target answers to.
143
+ const targetOf = (who, named, language, role) => {
144
+ const target = language.targets[named];
145
+ if (!target || isStore(target) !== (role === 'store')) {
146
+ 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.`
148
+ : `${who} names schema "${named}", which is not a schema target: a schema target has kinds and rules, and does not declare capability "store".`);
149
+ }
150
+ if (role === 'schema' && typeof target.derive !== 'function') {
151
+ throw new Error(`${who} names "${named}" as its schema target, which declares no derive. A schema target assembles its own create, update, row and list shapes from the kayan's fields.`);
152
+ }
153
+ return target;
154
+ };
155
+ // Decides from data only: a callback with no parameter, or a row table no row
156
+ // of which admits the field. Anything taking an actor is left to request time.
157
+ const deniesReadOutright = (field) => {
158
+ const read = field.access?.read;
159
+ if (typeof read !== 'function')
160
+ return false;
161
+ const table = rowsOfField(read)?.rows;
162
+ if (table) {
163
+ const admitted = Object.values(table).some((row) => {
164
+ const list = admissions(row.fields);
165
+ return (list.includes('*') || list.includes(field.name)) && !list.includes(`!${field.name}`);
166
+ });
167
+ return !admitted;
168
+ }
169
+ if (read.length > 0)
170
+ return false;
171
+ try {
172
+ return read() === false;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ };
178
+ const globalRuns = new Set(Object.keys(defaultGlobalOperations));
179
+ const wrote = (value) => (value === null ? 'null' : value === undefined ? 'undefined' : `a ${typeof value}`);
180
+ const nameOf = (relationTo) => typeof relationTo === 'function' ? String(relationTo()?.name) : String(relationTo);
181
+ // A row's tenant is one row of the tenant kayan, filled from the session on create and never
182
+ // moved, so a field that loosens any of it would let a row be made or moved outside its tenant.
183
+ const tenantField = ({ where, field, language }) => {
184
+ const target = language.targets.tenant;
185
+ 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.`);
187
+ }
188
+ const bound = tenantDefaults(language, field);
189
+ const rules = bound.rules ?? {};
190
+ if (bound.targets?.tenant !== true) {
191
+ throw new Error(`${where} writes targets.tenant as ${JSON.stringify(bound.targets?.tenant)}, and a field is bound to the tenant with targets: { tenant: true }, or not at all.`);
192
+ }
193
+ // A store's or schema target's entry is used as built with the rules skipped, so it would
194
+ // drop the not-null and the restricting foreign key the rules say.
195
+ const shaped = Object.keys(bound.targets).find((key) => key !== 'tenant' && language.targets[key] && !isScreen(language.targets[key]));
196
+ if (shaped) {
197
+ throw new Error(`${where} is bound through targets.tenant and writes targets.${shaped}, which would build the field in place of the tenant's rules. Drop targets.${shaped}; a screen's entry is the only other one a tenant field takes.`);
198
+ }
199
+ if (bound.kind !== 'relationship' || bound.hasMany || nameOf(bound.relationTo) !== target.relationTo) {
200
+ throw new Error(`${where} is bound through targets.tenant, and a tenant field holds one row of "${target.relationTo}": write it as tenant.field, or as { kind: 'relationship', relationTo: '${target.relationTo}', targets: { tenant: true } } with no hasMany.`);
201
+ }
202
+ for (const [rule, value] of [['required', true], ['index', true], ['onDelete', 'restrict']]) {
203
+ if (rules[rule] === value)
204
+ continue;
205
+ throw new Error(`${where} is bound through targets.tenant and declares ${rule} ${JSON.stringify(rules[rule])}, and a tenant field is ${rule}: ${JSON.stringify(value)}. Drop the rule, and the tenant target's own applies.`);
206
+ }
207
+ if (providerOf(bound, 'create') !== 'application' || providerOf(bound, 'update') !== 'never') {
208
+ throw new Error(`${where} is bound through targets.tenant and declares providedBy ${JSON.stringify(rules.providedBy)}, and a tenant field is providedBy: { create: 'application', update: 'never' }: the tenant comes from the session and never changes. Drop the rule, and the tenant target's own applies.`);
209
+ }
210
+ if (rules.defaultValue !== undefined) {
211
+ throw new Error(`${where} is bound through targets.tenant and declares defaultValue, and a tenant field is filled from the session alone. Drop defaultValue.`);
212
+ }
213
+ return bound;
214
+ };
215
+ // A key written flat is refused with the nesting spelled out. A node holding both a
216
+ // sentence and a group, or nothing, is refused: either reading would destroy copy.
217
+ const flattenMessages = (where, messages, locales, parts = []) =>
218
+ // `Object.fromEntries`, not assignment: a key named `__proto__` assigned with `[]`
219
+ // runs the setter, so the copy would vanish and the prototype be whatever was written.
220
+ Object.fromEntries(Object.entries(messages).flatMap(([key, value]) => {
221
+ const path = [...parts, ...key.split('.')];
222
+ if (key.includes('.')) {
223
+ 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
+ }
225
+ if (typeof value === 'function')
226
+ return [[path.join('.'), value]];
227
+ // A primitive where a node belongs, which only plain data reaches. A bare string
228
+ // would otherwise reach the registry as one opaque string and pass the check.
229
+ if (!value || typeof value !== 'object') {
230
+ throw new Error(`Kayan "${where}" writes the message key "${path.join('.')}" as ${wrote(value)}, and a message is one string per declared locale, a group of those, or a function. Write one string per declared locale under it.`);
231
+ }
232
+ const entries = Object.entries(value);
233
+ // Before the sentence-and-group test, which `{ ar: 'x', en: null }` would pass.
234
+ const loose = entries.find(([, one]) => typeof one !== 'string' && typeof one !== 'function' && (!one || typeof one !== 'object'));
235
+ if (loose) {
236
+ throw new Error(`Kayan "${where}" writes "${loose[0]}" under the message key "${path.join('.')}" as ${wrote(loose[1])}, and every part of a message is a string, a group of parts, or a function. Write a string there, or drop the part.`);
237
+ }
238
+ const strings = entries.filter(([, one]) => typeof one === 'string').length;
239
+ if (strings && strings < entries.length) {
240
+ throw new Error(`Kayan "${where}" writes the message key "${path.join('.')}" as a sentence and as a group at once, and a key is one or the other. Give the sentence a part of its own under "${path.join('.')}".`);
241
+ }
242
+ if (!entries.length) {
243
+ // In a `try`: a Proxy's trap or a `constructor` getter can throw, and that would
244
+ // lose the refusal being built.
245
+ let shape = 'an object';
246
+ try {
247
+ const prototype = Object.getPrototypeOf(value);
248
+ if (prototype === null || prototype === Object.prototype) {
249
+ shape = 'an empty object';
250
+ }
251
+ else {
252
+ // Read only in this branch: a plain object whose `constructor` throws is an empty object.
253
+ const named = value.constructor?.name;
254
+ if (named)
255
+ shape = `a ${named}`;
256
+ }
257
+ }
258
+ catch {
259
+ // The value would not say what it is, so the sentence says what it can.
260
+ }
261
+ throw new Error(`Kayan "${where}" writes the message key "${path.join('.')}" as ${shape}, so it is a sentence in no locale and a group with no parts. Write one string per declared locale under it, or drop the key.`);
262
+ }
263
+ if (strings) {
264
+ // A key that is no declared locale is a part meant to nest, written as a string:
265
+ // `merge` keys by declared locales alone, so it would be silently dropped.
266
+ const stray = locales.length ? entries.find(([locale]) => !locales.includes(locale)) : undefined;
267
+ if (stray) {
268
+ throw new Error(`Kayan "${where}" writes "${stray[0]}" under the message key "${path.join('.')}", and it is no locale this language declares: ${locales.join(', ')}. Write one string per declared locale under it, or nest "${stray[0]}" as a part of its own.`);
269
+ }
270
+ return [[path.join('.'), value]];
271
+ }
272
+ return Object.entries(flattenMessages(where, value, locales, path));
273
+ }));
274
+ export const resolve = (kayanList, language) => {
275
+ const pointsAtRow = (kind) => language.kinds[kind]?.pointsAtRow === true;
276
+ const duplicate = (values) => values.find((value, at) => values.indexOf(value) !== at);
277
+ const duplicateName = duplicate(kayanList.map(({ name }) => name));
278
+ if (duplicateName)
279
+ throw new Error(`Kayan name "${duplicateName}" is declared more than once.`);
280
+ const reserved = kayanList.find(({ name }) => reservedKayan.has(name));
281
+ if (reserved)
282
+ throw new Error(`Kayan name "${reserved.name}" is reserved: ${reservedKayan.get(reserved.name)}`);
283
+ // The one field name the libraries cannot hold, whatever our records do.
284
+ const proto = kayanList.find(({ fields }) => Object.hasOwn(fields, '__proto__'));
285
+ if (proto)
286
+ throw new Error(`Kayan "${proto.name}" field "__proto__" is reserved: Zod drops its value and drizzle's pgTable assigns it with Object.assign, which replaces the table's prototype.`);
287
+ // The push and the pull read a table name as a glob, so `*`, `?`, `[` or a leading
288
+ // `!` would widen the filter to tables the language did not build.
289
+ const glob = kayanList.find(({ name, dbName }) => !isPlainIdentifier(dbName ?? name));
290
+ if (glob)
291
+ throw new Error(`Kayan "${glob.name}" asks for the table name ${JSON.stringify(glob.dbName ?? glob.name)}, which is not a plain SQL identifier: letters, digits and underscores, not starting with a digit, at most 63 bytes. Give the kayan a dbName that is.`);
292
+ const duplicateTable = duplicate(kayanList.map(({ name, dbName }) => dbName ?? name));
293
+ if (duplicateTable)
294
+ throw new Error(`Table name "${duplicateTable}" is declared more than once.`);
295
+ // For a table written as plain data, which `kind` does not type. A rule may ship no
296
+ // sentence; every kind refuses the value it is handed.
297
+ for (const [name, kind] of Object.entries(language.kinds)) {
298
+ if (!kind.message) {
299
+ throw new Error(`The language's kind "${name}" declares no message, so a value it refuses has no sentence to show.`);
300
+ }
301
+ }
302
+ // A typo would otherwise exclude nothing in silence.
303
+ for (const [name, one] of Object.entries(language.rules)) {
304
+ const missing = one.excludes?.find((other) => !Object.hasOwn(language.rules, other));
305
+ if (missing) {
306
+ throw new Error(`The language's rule "${name}" excludes "${missing}", which is not a rule the language names.`);
307
+ }
308
+ }
309
+ const resolvedIndexes = new Map();
310
+ for (const kayan of kayanList) {
311
+ // Every operation that addresses a row compares the id column, so a kayan with none
312
+ // reaches the store with both operands undefined.
313
+ if (!kayan.fields.id) {
314
+ throw new Error(`Kayan "${kayan.name}" declares no field "id", and every operation that addresses a row does so by that id. Spread "defaultFields", or declare "id".`);
315
+ }
316
+ const columns = Object.entries(kayan.fields)
317
+ .filter(([, field]) => field.kind !== 'join')
318
+ .map(([name, field]) => field.dbName ?? (field.relationTo ? `${snakeCase(name)}_id` : snakeCase(name)));
319
+ const duplicateColumn = duplicate(columns);
320
+ if (duplicateColumn) {
321
+ throw new Error(`Kayan "${kayan.name}" column name "${duplicateColumn}" is declared more than once.`);
322
+ }
323
+ const owner = Object.keys(kayan.fields).find((name) => kayan.fields[name].targets?.tenant !== undefined);
324
+ // Local sign-in finds an account by its email and tokens alone, so per-tenant values would
325
+ // let one tenant's member register another's address and either row answer.
326
+ if (owner && kayan.auth) {
327
+ throw new Error(`Kayan "${kayan.name}" authenticates accounts and places tenant field "${owner}", and an account is one identity across every organization, looked up by its email and tokens alone. Drop the tenant field: an account kayan is shared.`);
328
+ }
329
+ // A file's key is the address of one object in a store every tenant shares, so it stays unique
330
+ // across the table: per tenant, two rows could name one object and serve or delete each other's.
331
+ const filed = Object.keys(language.targets).some((name) => isStorage(language.targets[name]) && kayan.targets?.[name] !== undefined);
332
+ const unique = Object.entries(kayan.fields)
333
+ .filter(([, field]) => field.rules?.unique === true && field.kind !== 'join' && !field.hasMany)
334
+ .map(([name]) => name);
335
+ // Unique within the tenant on a tenant-owned kayan, so one tenant never learns that another
336
+ // holds a value, nor takes one another wants. A join and a `hasMany` field are refused for
337
+ // `unique` further down, by name.
338
+ const defaults = unique.map((name) => {
339
+ if (kayan.fields[name].rules?.localized) {
340
+ throw new Error(`Kayan "${kayan.name}" field "${name}" is unique and localized, and a unique index is over one column while a localized field is one column per locale. Drop one of the two rules.`);
341
+ }
342
+ const index = { fields: owner && name !== owner && !(filed && name === 'key') ? [owner, name] : [name], unique: true };
343
+ uniqueRuleIndexes.add(index);
344
+ return index;
345
+ });
346
+ const declared = kayan.indexes;
347
+ if (declared !== undefined && typeof declared !== 'function' && !Array.isArray(declared)) {
348
+ throw new Error(`Kayan "${kayan.name}" writes indexes that are not a list. Write indexes: [{ fields: ['a', 'b'] }].`);
349
+ }
350
+ const listed = typeof declared === 'function' ? declared({ defaults }) : [...(declared ?? []), ...defaults];
351
+ if (!Array.isArray(listed)) {
352
+ throw new Error(`Kayan "${kayan.name}" indexes answered ${wrote(listed)}, and the function answers the whole list: indexes: ({ defaults }) => [...defaults].`);
353
+ }
354
+ // Expanded here, so every store builds the flat list it always built. Each index keeps the
355
+ // name a refusal calls it by.
356
+ const names = [];
357
+ const indexes = listed.flatMap((entry, at) => {
358
+ if (uniqueRuleIndexes.has(entry)) {
359
+ names.push(`the default unique index over "${entry.fields.join('", "')}"`);
360
+ return [entry];
361
+ }
362
+ if (!entry || typeof entry !== 'object' || !Object.hasOwn(entry, 'within')) {
363
+ names.push(`indexes[${at}]`);
364
+ return [entry];
365
+ }
366
+ const where = `Kayan "${kayan.name}" indexes[${at}]`;
367
+ const { within, unique: uniques = [], index: plain = [] } = entry;
368
+ // Only the tenant field: a group is the tenant's own uniqueness, and a plain entry says the rest.
369
+ if (!owner || within !== owner) {
370
+ throw new Error(owner
371
+ ? `${where} is a group within ${JSON.stringify(within)}, and a group is within the kayan's tenant field, "${owner}". Write within: '${owner}', or write each index as { fields: [...] }.`
372
+ : `${where} is a group within ${JSON.stringify(within)}, and the kayan places no tenant field, so its rows are within no tenant. Write each index as { fields: [...] }.`);
373
+ }
374
+ const stray = Object.keys(entry).find((key) => key !== 'within' && key !== 'unique' && key !== 'index');
375
+ if (stray !== undefined)
376
+ throw new Error(`${where} writes "${stray}", and a group takes within, unique and index alone.`);
377
+ for (const [key, list] of [['unique', uniques], ['index', plain]]) {
378
+ if (!Array.isArray(list)) {
379
+ throw new Error(`${where} writes ${key} as ${JSON.stringify(list)}, and a group's unique and index are lists of fields and of lists of fields: unique: ['a', ['b', 'c']].`);
380
+ }
381
+ // A typo otherwise, and as a unique one it would hold the tenant to a single row.
382
+ if (list.some((fields) => Array.isArray(fields) && !fields.length)) {
383
+ throw new Error(`${where} writes an empty list in ${key}, which would be an index over "${owner}" alone. Name the fields it is over, or drop the empty list.`);
384
+ }
385
+ }
386
+ const group = [
387
+ ...uniques.map((fields) => ({ fields: [within, ...[fields].flat()], unique: true })),
388
+ ...plain.map((fields) => ({ fields: [within, ...[fields].flat()] })),
389
+ ];
390
+ if (!group.length) {
391
+ throw new Error(`${where} is a group within "${owner}" that names no index. Write unique: ['a', ['b', 'c']], index: ['d'], or both.`);
392
+ }
393
+ for (const one of group)
394
+ names.push(`indexes[${at}]'s index over "${one.fields.join('", "')}"`);
395
+ return group;
396
+ });
397
+ resolvedIndexes.set(kayan, indexes);
398
+ // A join, a `hasMany` relationship and a localized field hold no single column of
399
+ // this table, so no index covers them. Checked here for a kayan written as plain data.
400
+ const seen = new Map();
401
+ indexes.forEach((index, at) => {
402
+ const where = `Kayan "${kayan.name}" ${names[at]}`;
403
+ const { fields, unique, nullsNotDistinct } = (index ?? {});
404
+ if (!Array.isArray(fields) || !fields.length) {
405
+ throw new Error(`${where} names no field, and an index is built over at least one. Write it as { fields: ['a', 'b'] }.`);
406
+ }
407
+ // A misspelt `unique` would otherwise build an index that is not.
408
+ const stray = Object.keys(index).find((key) => key !== 'fields' && key !== 'unique' && key !== 'nullsNotDistinct');
409
+ if (stray !== undefined)
410
+ throw new Error(`${where} writes "${stray}", and an index takes fields, unique and nullsNotDistinct alone.`);
411
+ if (unique !== undefined && typeof unique !== 'boolean') {
412
+ throw new Error(`${where} writes unique as ${JSON.stringify(unique)}, and unique is true or false.`);
413
+ }
414
+ if (nullsNotDistinct !== undefined && typeof nullsNotDistinct !== 'boolean') {
415
+ throw new Error(`${where} writes nullsNotDistinct as ${JSON.stringify(nullsNotDistinct)}, and nullsNotDistinct is true or false.`);
416
+ }
417
+ if (nullsNotDistinct !== undefined && unique !== true) {
418
+ throw new Error(`${where} writes nullsNotDistinct without unique: true, and only a unique index treats nulls as equal or distinct. Add unique: true, or drop nullsNotDistinct.`);
419
+ }
420
+ for (const name of fields) {
421
+ const field = typeof name === 'string' && Object.hasOwn(kayan.fields, name) ? kayan.fields[name] : undefined;
422
+ if (!field)
423
+ throw new Error(`${where} names ${JSON.stringify(name)}, which is no field of the kayan.`);
424
+ const notColumn = field.hasMany
425
+ ? 'a relationship with hasMany, which the store holds as a join table and not as a column of this kayan'
426
+ : field.kind === 'join'
427
+ ? 'a join, which is no column of this kayan: its rows are the other kayan\'s'
428
+ : field.rules?.localized
429
+ ? 'which is localized and so one column per locale rather than one column'
430
+ : undefined;
431
+ if (notColumn)
432
+ throw new Error(`${where} names "${name}", ${notColumn}. Drop it from the index.`);
433
+ }
434
+ const twice = duplicate(fields);
435
+ if (twice)
436
+ throw new Error(`${where} names "${twice}" twice.`);
437
+ const key = JSON.stringify(fields);
438
+ if (seen.has(key)) {
439
+ throw new Error(`Kayan "${kayan.name}" ${names[seen.get(key)]} and ${names[at]} are over the same fields in the same order, so they would be one index built twice. Keep one; a unique index serves every read the other would.`);
440
+ }
441
+ seen.set(key, at);
442
+ });
443
+ const bare = unique.find((name) => !indexes.some((index) => index.unique && index.fields.includes(name)));
444
+ if (bare) {
445
+ throw new Error(`Kayan "${kayan.name}" field "${bare}" is unique, and no unique index of the kayan covers it, so nothing would refuse a second row of one value. Keep its default in indexes, or drop unique.`);
446
+ }
447
+ }
448
+ const service = serviceTargetOf(language);
449
+ // Checked on the target itself, so a wrong default is refused where it is written.
450
+ targetOf('The service target', service.store, language, 'store');
451
+ targetOf('The service target', service.schema, language, 'schema');
452
+ // One window per build: a second versions target would leave each kayan reading whichever came first.
453
+ const versionTargets = Object.entries(language.targets).filter(([, target]) => isVersions(target));
454
+ if (versionTargets.length > 1) {
455
+ throw new Error(`Targets "${versionTargets.map(([key]) => key).join('" and "')}" both declare capability "versions", and a build has at most one versions target. Drop one of them.`);
456
+ }
457
+ const versioning = versionTargets[0];
458
+ // One key, so the target a field's `targets.tenant` binds to is never a guess.
459
+ const tenantTarget = language.targets.tenant;
460
+ const misnamed = Object.entries(language.targets).find(([name, target]) => isTenant(target) !== (name === 'tenant'));
461
+ if (misnamed) {
462
+ throw new Error(misnamed[0] === 'tenant'
463
+ ? `Target "tenant" declares capability "${String(misnamed[1].capability)}", and the key "tenant" is the tenant target's, which a field binds to through targets.tenant. Name this target for what it is.`
464
+ : `Target "${misnamed[0]}" declares capability "tenant" and has to be named "tenant" in targets, because a field binds to it through targets.tenant.`);
465
+ }
466
+ if (isTenant(tenantTarget) && tenantTarget.onDelete !== 'restrict') {
467
+ throw new Error(`The tenant target declares onDelete ${JSON.stringify(tenantTarget.onDelete)}, and a tenant takes onDelete: 'restrict' alone.`);
468
+ }
469
+ const byName = new Map(kayanList.map((kayan) => [kayan.name, kayan]));
470
+ const resolved = kayanList.map((kayan) => {
471
+ if (kayan.targets && Object.hasOwn(kayan.targets, 'tenant')) {
472
+ throw new Error(`Kayan "${kayan.name}" writes targets.tenant, and a kayan is tenant-owned by the field it places, tenant.field, and shared without one. Drop targets.tenant from the kayan.`);
473
+ }
474
+ const fields = Object.fromEntries(Object.entries(kayan.fields).map(([name, field]) => {
475
+ if (field.targets?.tenant !== undefined)
476
+ return [name, tenantField({ where: `Kayan "${kayan.name}" field "${name}"`, field, language })];
477
+ // A spread that writes its own `targets` drops the binding and keeps the rest of
478
+ // `tenant.field`, which would quietly leave the kayan shared.
479
+ const provided = field.rules?.providedBy;
480
+ if (isTenant(tenantTarget) && field.kind === 'relationship' && nameOf(field.relationTo) === tenantTarget.relationTo
481
+ && provided?.create === 'application' && provided.update === 'never' && Object.keys(provided).length === 2) {
482
+ throw new Error(`Field "${kayan.name}.${name}" looks like the tenant field but is not bound to the tenant target, so its kayan would be shared. Add targets: { tenant: true } to it.`);
483
+ }
484
+ return [name, field];
485
+ }));
486
+ const owners = Object.keys(fields).filter((name) => fields[name].targets?.tenant !== undefined);
487
+ if (owners.length > 1) {
488
+ throw new Error(`Kayan "${kayan.name}" binds fields "${owners.join('" and "')}" to the tenant, and a row belongs to one tenant. Keep one of them.`);
489
+ }
490
+ // Refused at the root, where `operations` would leave the kayan answering nothing and
491
+ // `store` would land writes in another store. Checked for kayan written as plain data.
492
+ for (const key of kayanServiceKeys) {
493
+ if (!Object.hasOwn(kayan, key))
494
+ continue;
495
+ throw new Error(`Kayan "${kayan.name}" writes "${key}" at its root, and it is a key of the kayan's service target now. Write it as targets: { service: { ${key}: ... } }.`);
496
+ }
497
+ const own = (kayan.targets?.service ?? {});
498
+ for (const key of Object.keys(own)) {
499
+ if (kayanServiceKeys.includes(key))
500
+ continue;
501
+ 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
+ }
503
+ return {
504
+ ...kayan,
505
+ // Flattened here, so every reader of a resolved kayan sees the key space a generated file carries.
506
+ ...(kayan.messages && { messages: flattenMessages(kayan.name, kayan.messages, language.locales ?? []) }),
507
+ table: kayan.dbName ?? kayan.name,
508
+ indexes: resolvedIndexes.get(kayan),
509
+ store: own.store ?? service.store,
510
+ schema: own.schema ?? service.schema,
511
+ // Written onto the kayan, because the runner and the managed reads hold the resolved kayan, not the language.
512
+ ...(service.softDelete && kayan.fields[service.softDelete] ? { softDelete: service.softDelete } : {}),
513
+ // Run again for a kayan written as plain data. `false` would remove nothing from
514
+ // the kayan's own record, so it is refused by name.
515
+ operations: Object.fromEntries(Object.entries(resolveOperations(own.operations, service)).filter(([operation, declaration]) => {
516
+ if (declaration === false) {
517
+ 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
+ }
519
+ if (declaration && typeof declaration === 'object' && 'steps' in declaration)
520
+ return true;
521
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" is not an operation declaration.`);
522
+ })),
523
+ ...(owners.length && { tenant: owners[0] }),
524
+ // No prototype, so a query naming `toString` finds no field rather than a function.
525
+ fields: Object.assign(Object.create(null), Object.fromEntries(Object.entries(fields).map(([name, field]) => [
526
+ name,
527
+ {
528
+ ...field,
529
+ name,
530
+ // Filled here, so every target's `required` rule always runs.
531
+ rules: {
532
+ ...field.rules,
533
+ required: field.rules?.required ?? false,
534
+ // A join holds no column here, so `providedBy: never` keeps the key off every write shape.
535
+ ...(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),
540
+ }),
541
+ },
542
+ // A join builds no column, but takes a name so the one place that prints it prints no blank.
543
+ column: field.dbName ?? (pointsAtRow(field.kind) && field.relationTo ? `${snakeCase(name)}_id` : snakeCase(name)),
544
+ // A target's maps take a `ResolvedField` and nothing else, so the facts they need are written onto it.
545
+ kayan: kayan.name,
546
+ schemaTarget: own.schema ?? service.schema,
547
+ // Written on every field, so a `locales` a declaration wrote itself is dropped.
548
+ locales: field.rules?.localized ? language.locales : undefined,
549
+ now: language.kinds[field.kind]?.now,
550
+ kindTargets: language.kinds[field.kind]?.targets,
551
+ operators: language.kinds[field.kind]?.operators,
552
+ sortable: language.kinds[field.kind]?.sortable,
553
+ target: typeof field.relationTo === 'string' ? byName.get(field.relationTo) : field.relationTo?.(),
554
+ },
555
+ ]))),
556
+ };
557
+ });
558
+ // A second pass, since two kayan may point at each other.
559
+ const resolvedByName = new Map(resolved.map((kayan) => [kayan.name, kayan]));
560
+ // The targets that shape a field; the access, service and screen targets shape none.
561
+ const targets = Object.entries(language.targets).filter(([, target]) => !isAccess(target) && !isScreen(target) && !isService(target) && !isVersions(target) && !isStorage(target) && !isTenant(target));
562
+ // A `targets.<name>` naming no target is a typo, not an override. `service` is left
563
+ // out: only a kayan writes it, and one written elsewhere is refused by name.
564
+ const targetNames = new Set(Object.entries(language.targets).filter(([, target]) => !isService(target)).map(([name]) => name));
565
+ const kayanTargetNames = new Set([...targetNames, ...Object.entries(language.targets).filter(([, target]) => isService(target)).map(([name]) => name)]);
566
+ const optionsNamed = (where, kind, field) => {
567
+ if (field.options && kind !== 'text' && kind !== 'textArray')
568
+ throw new Error(`${where} options belong on a text field or a list of text.`);
569
+ if (field.options) {
570
+ const values = new Set();
571
+ for (const option of field.options) {
572
+ if (typeof option.value !== 'string' || values.has(option.value))
573
+ throw new Error(`${where} options need distinct string values.`);
574
+ values.add(option.value);
575
+ if (!option.label || typeof option.label !== 'object' ||
576
+ (language.locales ?? []).some((locale) => typeof option.label[locale] !== 'string')) {
577
+ throw new Error(`${where} option "${option.value}" needs a label in every declared locale.`);
578
+ }
579
+ }
580
+ }
581
+ };
582
+ const affixesNamed = (where, kind, field) => {
583
+ for (const key of ['prefix', 'suffix', 'placeholder']) {
584
+ const copy = field[key];
585
+ // A function is the label hatch, resolved by the project's own runtime.
586
+ if (copy === undefined || typeof copy === 'function')
587
+ continue;
588
+ if (key !== 'placeholder' && !['text', 'number', 'integer', 'decimal'].includes(kind))
589
+ throw new Error(`${where} has a ${key}, and kind "${kind}" takes none; only a text, number, integer or decimal field does.`);
590
+ for (const locale of language.locales ?? []) {
591
+ if (typeof copy?.[locale] !== 'string')
592
+ throw new Error(`${where} ${key} is short of locale "${locale}".`);
593
+ }
594
+ }
595
+ };
596
+ const declaredTargets = (where, wrote, known = targetNames) => {
597
+ for (const name of Object.keys(wrote ?? {})) {
598
+ if (known.has(name))
599
+ continue;
600
+ // Not "no such target": the build has one. It is a key nobody reads.
601
+ if (isService(language.targets[name])) {
602
+ throw new Error(`${where} writes "targets.${name}", and nothing reads it: the keys a service target takes are a kayan's own, which are ${kayanServiceKeys.join(', ')}. Write them on the kayan, or drop this key.`);
603
+ }
604
+ throw new Error(`${where} writes "targets.${name}", which is no target of this build. The targets are ${[...known].join(', ')}.`);
605
+ }
606
+ };
607
+ for (const kayan of resolved) {
608
+ const store = targetOf(`Kayan "${kayan.name}"`, kayan.store, language, 'store');
609
+ targetOf(`Kayan "${kayan.name}"`, kayan.schema, language, 'schema');
610
+ for (const field of Object.values(kayan.fields)) {
611
+ const where = `Kayan "${kayan.name}" field "${field.name}"`;
612
+ declaredTargets(where, field.targets);
613
+ // The field found must be a relationship pointing back here, or the join would read
614
+ // a column that is no pointer back and answer another row's rows.
615
+ if (field.kind === 'join') {
616
+ if (!field.relationTo) {
617
+ throw new Error(`${where} is a join with no relationTo, so it points at nothing.`);
618
+ }
619
+ const target = field.target && resolvedByName.get(field.target.name);
620
+ if (!target) {
621
+ throw new Error(`${where} points at kayan "${nameOf(field.relationTo)}", which is not in the list passed to buildAsas. Add it to that list.`);
622
+ }
623
+ field.target = target;
624
+ const back = Object.hasOwn(target.fields, field.on ?? '') ? target.fields[field.on] : undefined;
625
+ if (!back || back.kind !== 'relationship' || back.hasMany || back.target?.name !== kayan.name) {
626
+ throw new Error(`${where} joins kayan "${target.name}" on "${field.on}", which is not a relationship back to "${kayan.name}". Name the field on "${target.name}" that holds this row's id.`);
627
+ }
628
+ // A field no actor may read would turn every read of this kayan at a depth into that child's 403.
629
+ if (deniesReadOutright(back)) {
630
+ throw new Error(`${where} joins kayan "${target.name}" on "${field.on}", whose read access is denied outright, so every read of this kayan at a depth would be refused 403.`);
631
+ }
632
+ // A join's page is a page of the child's `find`, so it is held to what a caller may ask for.
633
+ if (field.defaultLimit !== undefined && !(Number.isInteger(field.defaultLimit) && field.defaultLimit >= 1 && field.defaultLimit <= service.maxLimit)) {
634
+ throw new Error(`${where} has defaultLimit ${JSON.stringify(field.defaultLimit)}, and a join's page is a whole number from 1 to service.maxLimit ${service.maxLimit}.`);
635
+ }
636
+ // Held at build to what the child's read takes, or a read past it would lose every
637
+ // parent page at a depth to the child's 400 or 403.
638
+ if (field.defaultSort !== undefined) {
639
+ const sort = field.defaultSort;
640
+ if (typeof sort !== 'string' || !/^-?[A-Za-z0-9_]+$/.test(sort)) {
641
+ throw new Error(`${where} has defaultSort ${JSON.stringify(sort)}, and a join's sort is one field of kayan "${target.name}", optionally prefixed with "-".`);
642
+ }
643
+ const name = sort.replace(/^-/, '');
644
+ if (!Object.hasOwn(target.fields, name))
645
+ throw new Error(`${where} has defaultSort "${sort}", which is no field of kayan "${target.name}".`);
646
+ if (unsortable(target.fields[name]) || deniesReadOutright(target.fields[name])) {
647
+ throw new Error(`${where} has defaultSort "${sort}", and kayan "${target.name}" cannot be ordered by "${name}".`);
648
+ }
649
+ }
650
+ // Read off the declaration: `resolve` has already written its own `providedBy` above.
651
+ const declaredRules = byName.get(kayan.name)?.fields[field.name]?.rules;
652
+ const columnRule = ['defaultValue', 'index', 'localized', 'onDelete', 'providedBy', 'unique'].find((rule) => declaredRules?.[rule] !== undefined);
653
+ if (columnRule) {
654
+ throw new Error(`${where} is a join, which holds no column of this kayan and no value a caller sends, so rule "${columnRule}" would build nothing. Declare it on kayan "${target.name}", or drop it.`);
655
+ }
656
+ }
657
+ if (pointsAtRow(field.kind)) {
658
+ if (!language.kinds[field.kind].onDelete) {
659
+ throw new Error(`${where} has kind "${field.kind}", which points at a row and says no onDelete default. Give the kind its own onDelete.`);
660
+ }
661
+ if (!field.relationTo) {
662
+ throw new Error(`${where} is a ${field.kind} with no relationTo, so it points at nothing.`);
663
+ }
664
+ const declared = typeof field.relationTo === 'function' ? field.relationTo() : undefined;
665
+ if (declared && byName.has(declared.name) && byName.get(declared.name) !== declared) {
666
+ throw new Error(`${where} points at an unrelated kayan object named "${declared.name}" instead of the registered kayan.`);
667
+ }
668
+ const target = field.target && resolvedByName.get(field.target.name);
669
+ if (!target) {
670
+ throw new Error(`${where} points at kayan "${nameOf(field.relationTo)}", which is not in the list passed to buildAsas. Add it to that list.`);
671
+ }
672
+ field.target = target;
673
+ // Refused on every value rather than carried unread.
674
+ if (Object.hasOwn(field, 'mode')) {
675
+ 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
+ }
677
+ // A rule that silently builds nothing is what this file exists to prevent.
678
+ // `required` is not one: the schema target reads it off the array.
679
+ if (field.hasMany) {
680
+ const columnRule = ['defaultValue', 'index', 'onDelete', 'unique'].find((rule) => field.rules?.[rule] !== undefined);
681
+ if (columnRule) {
682
+ 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
+ }
684
+ }
685
+ // No store can reference a row another store holds.
686
+ if (target.store !== kayan.store) {
687
+ throw new Error(`${where} points at kayan "${target.name}" in store "${target.store}", and this kayan is in store "${kayan.store}". A relationship cannot cross stores: declare the id as a text or uuid field and join in the app.`);
688
+ }
689
+ // Unreachable while every kayan without an `id` is refused above; it guards a target
690
+ // reaching this pass some other way.
691
+ if (!target.fields.id) {
692
+ throw new Error(`${where} points at kayan "${target.name}", which declares no field "id", so there is no id for the foreign key to take its type from.`);
693
+ }
694
+ }
695
+ optionsNamed(where, field.kind, field);
696
+ affixesNamed(where, field.kind, field);
697
+ for (const [screen, block] of Object.entries(field.targets ?? {})) {
698
+ if (!isScreen(language.targets[screen]))
699
+ continue;
700
+ const drawn = block;
701
+ const at = `${where} targets.${screen}`;
702
+ // The row it offers would be refused by the read every time it ran.
703
+ if (drawn.filter === true && deniesReadOutright(field)) {
704
+ throw new Error(`${at}.filter is set and the field denies read outright, so it offers a filter no actor can ever use.`);
705
+ }
706
+ // A field the read refuses `like` on would make every search a 400, and one no actor may read a 403.
707
+ if (drawn.searchable === true) {
708
+ if (!['email', 'text'].includes(field.kind)) {
709
+ throw new Error(`${at}.searchable is set, and its kind "${field.kind}" cannot take the "like" operator; only "text" and "email" can.`);
710
+ }
711
+ if (field.rules?.localized)
712
+ throw new Error(`${at}.searchable is set and the field is localized, and a search runs one "like" over one column.`);
713
+ if (deniesReadOutright(field))
714
+ throw new Error(`${at}.searchable is set and the field denies read outright, so every search over it would be refused 403.`);
715
+ }
716
+ // Refused on every kind rather than carried unread.
717
+ for (const key of ['allowCreate', 'defaultColumns', 'inline']) {
718
+ if (Object.hasOwn(block, key)) {
719
+ throw new Error(`${at}.${key} is set, and it draws nothing: a one-to-many relationship is a page's list node now, { list: { of, where } }.`);
720
+ }
721
+ }
722
+ }
723
+ // `primary` is the store's own, never a rule a field carries, so the
724
+ // field named `id` is where its entry is checked for.
725
+ if (field.name === 'id' && !store.rules.primary) {
726
+ throw new Error(`${where} is the primary key, and "primary" has no entry in the "${kayan.store}" target's rules.`);
727
+ }
728
+ // Without this the mismatch is Postgres refusing the pushed schema at connect.
729
+ if (field.name === 'id') {
730
+ for (const [name, target] of targets) {
731
+ if (target.idKinds && !target.idKinds.includes(field.kind)) {
732
+ throw new Error(`${where} is the primary key with kind "${field.kind}", which the "${name}" target builds no id of. An id may be ${target.idKinds.map((one) => `"${one}"`).join(', ')}.`);
733
+ }
734
+ }
735
+ // Every foreign key to the id is built without its rules, so `maxLength` on the
736
+ // id would make it `varchar(n)` against plain `text` keys.
737
+ if (typeof field.rules?.maxLength === 'number') {
738
+ throw new Error(`${where} is the primary key, and "maxLength" on it would build a column and a shape no foreign key pointing at it shares. Declare the maximum on a field that is not the id.`);
739
+ }
740
+ }
741
+ // Both sizes or neither: a scale past the precision would leave the whole part no digit.
742
+ if (field.kind === 'decimal') {
743
+ const { precision, scale } = field.rules ?? {};
744
+ if (typeof precision !== typeof scale) {
745
+ throw new Error(`${where} declares ${typeof precision === 'number' ? 'precision' : 'scale'} and needs ${typeof precision === 'number' ? 'scale' : 'precision'} too, or neither.`);
746
+ }
747
+ if (typeof precision === 'number' && Number(scale) > precision) {
748
+ throw new Error(`${where} needs a scale no greater than its precision.`);
749
+ }
750
+ }
751
+ // Never a silent skip: a kind has an entry on every target or throws.
752
+ for (const [name, target] of targets) {
753
+ if (!implementationOf({ table: target.kinds, name: field.kind, own: language.kinds[field.kind]?.targets, type: target.type })) {
754
+ throw new Error(`${where} has kind "${field.kind}", which has no entry in the "${name}" target's kinds${target.type ? ` and carries none of its own under targets.${target.type}` : ''}.`);
755
+ }
756
+ }
757
+ // A key that is neither a rule nor one of its kind's own is a typo, and says so.
758
+ const own = field.kind === 'join' ? joinKeys : pointsAtRow(field.kind) ? relationshipKeys : undefined;
759
+ for (const key of Object.keys(field)) {
760
+ if (fixed.has(key) || own?.has(key))
761
+ continue;
762
+ if (relationshipKeys.has(key) || joinKeys.has(key)) {
763
+ throw new Error(`${where} has kind "${field.kind}" and carries key "${key}", which only a ${relationshipKeys.has(key) ? 'relationship' : 'join'} may carry.`);
764
+ }
765
+ throw new Error(`${where} carries key "${key}", which is not a field key; put rules under "rules".`);
766
+ }
767
+ for (const [key, value] of Object.entries(field.rules ?? {})) {
768
+ if (!Object.hasOwn(language.rules, key)) {
769
+ throw new Error(`${where} carries nested rule "${key}", which is not a rule the language names.`);
770
+ }
771
+ if (!language.rules[key].accepts(value)) {
772
+ throw new Error(`${where} nested rule "${key}" has an invalid value type.`);
773
+ }
774
+ const accepted = language.kinds[field.kind]?.rules;
775
+ if (!accepted?.includes(key)) {
776
+ throw new Error(`${where} has kind "${field.kind}" which does not accept nested rule "${key}".`);
777
+ }
778
+ // Refused here rather than at the first write it would fill.
779
+ if ((key === 'defaultNow' || key === 'nowOnUpdate') && value === true && !language.kinds[field.kind].now) {
780
+ throw new Error(`${where} nested rule "${key}" does not match kind "${field.kind}", which says no moment to write.`);
781
+ }
782
+ // `nowOnUpdate` is the runner supplying the value on every update, so any other
783
+ // provider contradicts it.
784
+ if (key === 'nowOnUpdate' && value === true && providerOf(field, 'update') !== 'application') {
785
+ throw new Error(`${where} declares "nowOnUpdate", which says the runner supplies the value on every update, and its providedBy.update is "${providerOf(field, 'update')}". Declare providedBy: { update: 'application' }, or drop "nowOnUpdate".`);
786
+ }
787
+ if (key === 'defaultNow' && value === true && providerOf(field, 'create') === 'never') {
788
+ throw new Error(`${where} declares "defaultNow", which says the runner fills the value a create leaves out, and its providedBy.create is "never". Declare providedBy: { create: 'application' } or leave create to the client, or drop "defaultNow".`);
789
+ }
790
+ // The rule's accepted type is `number | string` on every kind, so this keeps `min: '3'` off a number.
791
+ if ((key === 'min' || key === 'max') && !language.kinds[field.kind].accepts(value)) {
792
+ throw new Error(`${where} nested rule "${key}" does not match kind "${field.kind}".`);
793
+ }
794
+ // The target's map order would decide which of the pair ran last, which nothing the
795
+ // field said. A rule switched off with `false` is not declared.
796
+ const excluded = value === false ? undefined : language.rules[key].excludes?.find((other) => field.rules?.[other] !== undefined && field.rules[other] !== false);
797
+ if (excluded) {
798
+ throw new Error(`${where} declares both "${key}" and "${excluded}", and the two cannot both hold: "${key}" excludes "${excluded}". Declare one of the two.`);
799
+ }
800
+ // A lower bound above its upper bound refuses every write naming a reasonable-looking
801
+ // rule. A decimal compares as the value it is, not as text.
802
+ if (key === 'min' || key === 'minLength' || key === 'minRows') {
803
+ const upper = key === 'min' ? 'max' : key === 'minLength' ? 'maxLength' : 'maxRows';
804
+ const most = field.rules?.[upper];
805
+ const impossible = most !== undefined &&
806
+ (field.kind === 'decimal' && key === 'min'
807
+ ? compareDecimals(String(value), String(most)) > 0
808
+ : typeof value === 'number' && typeof most === 'number' ? value > most : String(value) > String(most));
809
+ if (impossible) {
810
+ throw new Error(`${where} declares "${key}" ${JSON.stringify(value)} and "${upper}" ${JSON.stringify(most)}, and no value is both at once. Name a "${key}" no greater than the "${upper}".`);
811
+ }
812
+ }
813
+ // mithaq resolves no locale, so one column would be a guess at which locale the value is in.
814
+ if (key === 'localized' && value === true && !language.locales?.length) {
815
+ throw new Error(`${where} carries nested rule "localized", and this language declares no locales. Name them on defineMithaq, the way roles are named, so every target knows which columns and which keys a localized field has.`);
816
+ }
817
+ // A path's second segment reads as a locale or as a field of what the field points at,
818
+ // never both; a project kind named `relationship` could otherwise carry both.
819
+ if (key === 'localized' && value === true && field.relationTo) {
820
+ throw new Error(`${where} carries nested rule "localized" and a "relationTo", and a field is one or the other: a query path's second segment reads as a locale of this field or as a field of the kayan it points at, and a field carrying both would make the path mean two things.`);
821
+ }
822
+ // The default would reach a localized field as a string where the shape wants an
823
+ // object keyed by locale, so the create it serves is refused.
824
+ if (key === 'localized' && value === true && field.rules?.defaultValue !== undefined) {
825
+ throw new Error(`${where} declares both "localized" and a defaultValue, and the two contradict: the default is one literal on every locale column, and the defaults step then writes that literal where the shape wants an object keyed by locale. Declare one of the two.`);
826
+ }
827
+ if (key === 'defaultValue')
828
+ refuseLiteral(value, `${where} nested rule "defaultValue"`);
829
+ // Only the key is checked here: one an actor could carry, never a prototype name.
830
+ if (key === 'defaultValue' && isRef(value) && unsafeKeys.has(value.$user)) {
831
+ throw new Error(`${where} nested rule "defaultValue" names the actor key "${value.$user}", which no actor carries.`);
832
+ }
833
+ if (key === 'defaultValue' &&
834
+ !isRef(value) &&
835
+ !language.kinds[field.kind].accepts(value)) {
836
+ throw new Error(`${where} nested rule "defaultValue" does not match kind "${field.kind}".`);
837
+ }
838
+ for (const [name, target] of targets) {
839
+ if (!target.rules[key]) {
840
+ throw new Error(`${where} carries nested rule "${key}", which has no entry in the "${name}" target's rules.`);
841
+ }
842
+ }
843
+ }
844
+ }
845
+ // An operation's own relationship input draws a picker over the named kayan, so a
846
+ // name no kayan carries is refused rather than drawn over nothing.
847
+ for (const [operation, declaration] of Object.entries(kayan.operations)) {
848
+ declaredTargets(`Kayan "${kayan.name}" operation "${operation}"`, declaration.targets);
849
+ const inputs = Object.entries(declaration.targets ?? {}).flatMap(([screen, block]) => isScreen(language.targets[screen]) ? Object.entries(block.fields ?? {}) : []);
850
+ for (const [name, field] of inputs) {
851
+ const where = `Kayan "${kayan.name}" operation "${operation}" field "${name}"`;
852
+ declaredTargets(where, field.targets);
853
+ optionsNamed(where, field.kind, field);
854
+ affixesNamed(where, field.kind, field);
855
+ if (field.kind !== 'relationship')
856
+ continue;
857
+ const target = typeof field.relationTo === 'function' ? field.relationTo().name : field.relationTo;
858
+ if (!target) {
859
+ throw new Error(`${where} is a relationship with no relationTo, so it points at nothing.`);
860
+ }
861
+ if (!resolvedByName.has(target)) {
862
+ throw new Error(`${where} points at kayan "${target}", which is not in the list passed to buildAsas. Add it to that list.`);
863
+ }
864
+ }
865
+ }
866
+ // A mark the client supplies would be a delete performed under the create or update key.
867
+ declaredTargets(`Kayan "${kayan.name}"`, kayan.targets, kayanTargetNames);
868
+ const kept = versioning && kayan.targets?.[versioning[0]];
869
+ if (kept)
870
+ kayan.versions = versionsOf({ kayan, kept, name: versioning[0], window: versioning[1].max });
871
+ if (kayan.softDelete) {
872
+ const field = kayan.fields[kayan.softDelete];
873
+ const where = `Kayan "${kayan.name}" field "${field.name}" is the service target's soft-delete field`;
874
+ if (!language.kinds[field.kind].accepts(new Date())) {
875
+ throw new Error(`${where}, and kind "${field.kind}" does not hold a moment, which is what a delete writes into it.`);
876
+ }
877
+ if (field.rules?.required) {
878
+ throw new Error(`${where} and declares "required", and a row that has to carry the moment it was deleted can never be made.`);
879
+ }
880
+ if (field.rules?.localized) {
881
+ throw new Error(`${where} and declares "localized", and a mark spread over one column per locale is not one column a read can leave out.`);
882
+ }
883
+ const on = ['create', 'update'].find((one) => providerOf(field, one) === 'client');
884
+ if (on) {
885
+ throw new Error(`${where} and the client supplies it on ${on}, so a caller holding "${on}" could delete a row without the delete operation. Declare providedBy { create: "never", update: "never" }.`);
886
+ }
887
+ }
888
+ // The one place every global reaches, through `defineGlobal` or as plain data.
889
+ if (kayan.single) {
890
+ // Nothing injects `globalType`, so a global that did not spread it would fail at the store.
891
+ const { globalType } = kayan.fields;
892
+ const owner = kayan.tenant;
893
+ const tenant = owner && kayan.fields[owner];
894
+ if (!globalType) {
895
+ throw new Error(`Global "${kayan.name}" declares no field "globalType", and every global needs one, because the store seal writes the global's own name into it on every write. Spread defaultGlobalFields into its fields: fields: { ...defaultFields, ...defaultGlobalFields }.`);
896
+ }
897
+ // Postgres allows any number of nulls in a unique column, so the tenant is required too.
898
+ if (tenant && !(tenant.rules?.required === true && tenant.rules.unique === true)) {
899
+ throw new Error(`Global "${kayan.name}" declares tenant field "${owner}" without required and unique, and that column is what keeps the global to one row per tenant. Write it as ${owner}: { ...${owner}, rules: { ...${owner}.rules, required: true, unique: true } }.`);
900
+ }
901
+ const oneRow = !!tenant !== (globalType.rules?.unique === true);
902
+ if (!oneRow) {
903
+ throw new Error(tenant
904
+ ? `Global "${kayan.name}" declares a tenant and a unique "globalType", so a second tenant's row would collide with the first one's. Write globalType without unique: globalType: { ...defaultGlobalFields.globalType, rules: { required: true } }.`
905
+ : `Global "${kayan.name}" declares no tenant and a "globalType" that is not unique, so two first updates would leave two rows. Spread defaultGlobalFields, whose globalType is unique.`);
906
+ }
907
+ // The index, not the rule alone, is what refuses a second row when two first writes race.
908
+ const keeper = tenant ? owner : 'globalType';
909
+ if (!kayan.indexes.some((index) => index.unique && index.fields.length === 1 && index.fields[0] === keeper)) {
910
+ throw new Error(`Global "${kayan.name}" builds no unique index over "${keeper}" alone, and that index is what keeps the global to one row${tenant ? ' per tenant' : ''}. Keep it in indexes.`);
911
+ }
912
+ // The declaration, not the compiled object: a callback that is not the compiled
913
+ // table's own is one the global wrote.
914
+ const compiledRows = rowsOf(kayan.access);
915
+ const declared = kayan.access;
916
+ for (const operation of ['create', 'delete']) {
917
+ if (declared && operation in declared && declared[operation] !== compiledRows?.[operation]) {
918
+ throw new Error(`Global "${kayan.name}" declares access "${operation}", and a global has read and update only, which is Payload's GlobalAccess.`);
919
+ }
920
+ }
921
+ for (const operation of Object.keys(kayan.operations)) {
922
+ if (globalRuns.has(operation))
923
+ continue;
924
+ throw new Error(`Global "${kayan.name}" declares operation "${operation}", and a global runs "findOne" and "update" alone.`);
925
+ }
926
+ }
927
+ for (const [operation, declaration] of Object.entries(kayan.operations)) {
928
+ kayan.operations[operation] = sealed(kayan.name, operation, declaration, service);
929
+ // A version operation reads and writes the versions table, and a kayan
930
+ // that keeps no versions has none.
931
+ const held = kayan.operations[operation].steps.map(sealOf).find((one) => one && versionSeals.includes(one));
932
+ if (held && !kayan.versions) {
933
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" holds the "${held}" step, and the kayan keeps no versions. Write targets.${versioning?.[0] ?? 'versions'} on the kayan, or leave "${operation}" out of the object.`);
934
+ }
935
+ }
936
+ // Refused rather than dropped: a bulk without its single-row write cannot run.
937
+ for (const [operation, declaration] of Object.entries(kayan.operations)) {
938
+ const seal = declaration.steps.map(sealOf).find((held) => held && bulkSeals.includes(held));
939
+ if (!seal)
940
+ continue;
941
+ const single = seal === 'updateMany' ? 'update' : 'delete';
942
+ if (kayan.operations[single])
943
+ continue;
944
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" writes every matched row through operation "${single}", which this kayan does not have. Declare it, or leave "${operation}" out of the object.`);
945
+ }
946
+ }
947
+ // A language that names the mark and a build where nothing takes it makes every delete
948
+ // remove the row for good, with nothing said.
949
+ const deletes = resolved.some((kayan) => Object.values(kayan.operations).some((operation) => operation.effect === 'delete'));
950
+ if (service.softDelete && deletes && !resolved.some((kayan) => kayan.softDelete)) {
951
+ throw new Error(`The service target names "${service.softDelete}" as its soft-delete field, and no kayan in this build declares it, so every delete removes its row for good. Declare the field on the kayan that keep their rows, or drop "softDelete" from the service target.`);
952
+ }
953
+ return resolved;
954
+ };