@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,791 @@
1
+ // One list in one place, so drizzle's callback transaction is kept as it is.
2
+ import { rowsOfField } from '../../mithaq/rows.js';
3
+ import { heldPermissions } from '../../rbac/index.js';
4
+ import { authActor } from '../actor.js';
5
+ import { AfterCommitError, AsasError, denied, invalid, missing } from './errors.js';
6
+ import { defaultKinds } from '../../base/kinds.js';
7
+ import { providerOf } from '../../base/rules.js';
8
+ import { atEffect, defaults, issueRefusal, parse, sealOf, storeSeals, validated, withoutPrototype } from './steps.js';
9
+ import { firstRegisters, running, snapshot } from './runtime.js';
10
+ import { changedColumns, latestVersion, restoreInput, revived, saveVersion } from '../../versions/versions.js';
11
+ import { fileURLs } from '../../storage/storage.js';
12
+ import { combineWhere, operatorList } from '../../mithaq/where.js';
13
+ import { refuseTenantOption, writeTenant } from './tenant.js';
14
+ /** The key of `Options` only the bulk runner sets. */
15
+ export const ontoRow = Symbol('ontoRow');
16
+ const isScalar = (value) => value === null ||
17
+ ['string', 'boolean'].includes(typeof value) ||
18
+ (typeof value === 'number' && Number.isFinite(value)) ||
19
+ (value instanceof Date && Number.isFinite(value.getTime()));
20
+ const known = new Set(operatorList);
21
+ /** A list of operators as a sentence says it: `"a", "b" and "c"`. */
22
+ const and = new Intl.ListFormat('en-GB', { type: 'conjunction' });
23
+ const checkOperatorValue = (said, field, operator, value) => {
24
+ if (operator === 'exists') {
25
+ if (typeof value !== 'boolean')
26
+ throw new Error(`${said} field "${field}" operator "exists" needs a boolean.`);
27
+ return;
28
+ }
29
+ if (operator === 'in' || operator === 'not_in') {
30
+ if (!Array.isArray(value) || !value.every(isScalar)) {
31
+ throw new Error(`${said} field "${field}" operator "${operator}" needs scalar array.`);
32
+ }
33
+ return;
34
+ }
35
+ if ((operator === 'contains' || operator === 'like') && typeof value !== 'string') {
36
+ throw new Error(`${said} field "${field}" operator "${operator}" needs a string.`);
37
+ }
38
+ if (['greater_than', 'greater_than_equal', 'less_than', 'less_than_equal'].includes(operator) && value === null) {
39
+ throw new Error(`${said} field "${field}" operator "${operator}" cannot compare null.`);
40
+ }
41
+ if (!isScalar(value)) {
42
+ throw new Error(`${said} field "${field}" operator "${operator}" needs a scalar.`);
43
+ }
44
+ };
45
+ // A second segment is a locale or a related field, never both: `resolve` refuses
46
+ // `localized` beside `relationTo` on the field itself, whatever the kind is named.
47
+ const queryField = ({ kayan, key, said }) => {
48
+ const parts = key.split('.');
49
+ const unsupported = `${said} path "${key}" is unsupported. A path is one field, and may then name one related field of what a relationship points at or one locale of a localized field, so it is never more than two segments.`;
50
+ if (parts.length > 2 || parts.some((part) => !part))
51
+ throw new Error(unsupported);
52
+ const field = parts[0];
53
+ const path = parts[1];
54
+ const resolved = kayan.fields[field];
55
+ if (!resolved)
56
+ throw new Error(`${said} names unknown field "${key}".`);
57
+ if (!path || resolved.locales?.includes(path))
58
+ return { field, key, kind: resolved.kind, operators: resolved.operators };
59
+ if (!resolved.target)
60
+ throw new Error(unsupported);
61
+ const target = resolved.target.fields[path];
62
+ if (!target)
63
+ throw new Error(`${said} path "${key}" names unknown related field.`);
64
+ return { field, key, kind: target.kind, operators: target.operators };
65
+ };
66
+ // An access constraint and a screen's `when` share this one grammar.
67
+ export const assertFieldWhere = ({ said, key, kind, operators, value, valued = true }) => {
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
69
+ throw new Error(`${said} field "${key}" must use operators.`);
70
+ }
71
+ const fieldWhere = Object.create(null);
72
+ for (const [operator, operand] of Object.entries(value)) {
73
+ if (!known.has(operator)) {
74
+ throw new Error(`${said} field "${key}" uses unsupported operator "${operator}".`);
75
+ }
76
+ // A kind that names its operators is held to them and nothing else
77
+ // as rich text is to the three that search the words of its text.
78
+ if (operators && !operators.includes(operator)) {
79
+ throw new Error(`${said} field "${key}" operator "${operator}" is not supported on a field of kind "${kind}"; only ${and.format(operators.map((one) => `"${one}"`))} ${operators.length === 1 ? 'is' : 'are'}.`);
80
+ }
81
+ // Every operator here compares one value, so none means anything against a list
82
+ // column; drizzle's array mapper would throw a 500 first. `exists` asks about the column.
83
+ if (kind === 'textArray' && operator !== 'exists') {
84
+ throw new Error(`${said} field "${key}" operator "${operator}" is not supported on a list field; only "exists" is.`);
85
+ }
86
+ if (!operators && (operator === 'contains' || operator === 'like') && !['email', 'text'].includes(kind)) {
87
+ throw new Error(`${said} field "${key}" operator "${operator}" only supports text-like fields.`);
88
+ }
89
+ if (!operators &&
90
+ ['greater_than', 'greater_than_equal', 'less_than', 'less_than_equal'].includes(operator) &&
91
+ !['date', 'datetime', 'number', 'integer', 'decimal'].includes(kind)) {
92
+ throw new Error(`${said} field "${key}" operator "${operator}" only supports numeric, date and datetime fields.`);
93
+ }
94
+ checkOperatorValue(said, key, operator, operand);
95
+ // A value an integer or decimal column cannot hold would reach Postgres as a 500.
96
+ // A caller's own query skips this: its row shape refuses values with a path each.
97
+ if (valued && (kind === 'integer' || kind === 'decimal') && operator !== 'exists') {
98
+ for (const one of Array.isArray(operand) ? operand : [operand]) {
99
+ const read = kind === 'integer' && typeof one === 'string' && /^[+-]?\d+$/.test(one) ? Number(one) : one;
100
+ if (read !== null && !defaultKinds[kind].accepts(read))
101
+ throw new Error(`${said} field "${key}" operator "${operator}" needs a value of kind "${kind}".`);
102
+ }
103
+ }
104
+ fieldWhere[operator] = operand;
105
+ }
106
+ if (!Object.keys(fieldWhere).length)
107
+ throw new Error(`${said} field "${key}" has no operators.`);
108
+ return fieldWhere;
109
+ };
110
+ export const assertWhere = (kayan, where) => normalizedWhere({ kayan, where, valued: true, said: `Kayan "${kayan.name}" access constraint` });
111
+ // Grammar alone: a caller's values are the row shape's to refuse.
112
+ export const assertWhereGrammar = (kayan, where) => normalizedWhere({ kayan, where, valued: false, said: `Kayan "${kayan.name}" query` });
113
+ const normalizedWhere = ({ kayan, where, valued, said }) => {
114
+ if (!where || typeof where !== 'object' || Array.isArray(where)) {
115
+ throw new Error(`${said} is not an object of fields.`);
116
+ }
117
+ const entries = Object.entries(where);
118
+ if (!entries.length)
119
+ throw new Error(`${said} is empty.`);
120
+ // No prototype, so `__proto__` is a key like any other.
121
+ const normalized = Object.create(null);
122
+ for (const [key, value] of entries) {
123
+ if (key === 'and' || key === 'or') {
124
+ if (!Array.isArray(value) || !value.length) {
125
+ throw new Error(`${said} "${key}" must be a non-empty array.`);
126
+ }
127
+ normalized[key] = value.map((one) => normalizedWhere({ kayan, where: one, valued, said }));
128
+ continue;
129
+ }
130
+ const queried = queryField({ kayan, key, said });
131
+ const fieldWhere = assertFieldWhere({ said, key, kind: queried.kind, operators: queried.operators, value, valued });
132
+ normalized[queried.key] = { ...normalized[queried.key], ...fieldWhere };
133
+ }
134
+ return normalized;
135
+ };
136
+ // `exists` rather than `equals: null`: it asks about the column, which every store
137
+ // answers alike. The access target's reader never asks it, so `buildAsas` refuses the pair.
138
+ export const aliveWhere = (kayan) => kayan.softDelete ? { [kayan.softDelete]: { exists: false } } : undefined;
139
+ // A row made as a draft and never published is read by nothing but a draft read.
140
+ export const publishedWhere = (kayan) => combineWhere(aliveWhere(kayan), kayan.versions?.drafts ? { _status: { equals: 'published' } } : undefined);
141
+ const fieldAccess = async (operation, kayan, data, actor, id, named, doc, kept = []) => {
142
+ const next = { ...data };
143
+ const refused = [];
144
+ let table;
145
+ for (const field of Object.values(kayan.fields)) {
146
+ if (operation === 'update' && field.name === 'id')
147
+ continue;
148
+ if (!(field.name in next))
149
+ continue;
150
+ const access = field.access?.[operation];
151
+ if (!access)
152
+ continue;
153
+ // Asked again on every pass: a step may have changed the value the callback judges.
154
+ const allowed = !!(await access({ data, doc, id, siblingData: next, user: actor }));
155
+ if (allowed)
156
+ continue;
157
+ // A row-compiled denial is named, not silently dropped. A column carried from a draft
158
+ // is refused whatever denied it, or the stored value would publish unasked.
159
+ const source = rowsOfField(access);
160
+ if (!source && !kept.includes(field.name)) {
161
+ delete next[field.name];
162
+ continue;
163
+ }
164
+ table ??= source;
165
+ refused.push(field.name);
166
+ }
167
+ if (refused.length) {
168
+ // The roles go as a list, so a client resolves each against the catalog.
169
+ const roles = table?.roles(actor) ?? [];
170
+ throw denied({
171
+ message: `Forbidden: kayan "${kayan.name}" operation "${named}" for roles "${roles.join(', ')}" cannot write field${refused.length > 1 ? 's' : ''} "${refused.join('", "')}".`,
172
+ key: 'error.forbidden.field_write',
173
+ params: { kayan: kayan.name, operation: named, roles, fields: refused },
174
+ });
175
+ }
176
+ return next;
177
+ };
178
+ export const projectReadableFields = async ({ kayan, row, actor, }) => {
179
+ const next = { ...row };
180
+ for (const field of Object.values(kayan.fields)) {
181
+ const access = field.access?.read;
182
+ if (!access || !(field.name in next))
183
+ continue;
184
+ const allowed = await access({ data: row, doc: row, id: row.id, siblingData: row, user: actor });
185
+ if (!allowed)
186
+ delete next[field.name];
187
+ }
188
+ return next;
189
+ };
190
+ // A stored row the row shape refuses is the data's fault, so a plain `Error` (a masked
191
+ // 500). The message names the fields and id, never a value: a message may quote it.
192
+ const storedRow = async (kayan, schema, row) => {
193
+ const result = await schema['~standard'].validate(withoutPrototype(row));
194
+ if (!result.issues)
195
+ return result.value;
196
+ const fields = [...new Set(result.issues.map((issue) => {
197
+ const [first] = issue.path ?? [];
198
+ return String(typeof first === 'object' ? first.key : first ?? '');
199
+ }))];
200
+ const id = 'id' in row && !fields.includes('id') ? ` ${JSON.stringify(row.id)}` : '';
201
+ throw new Error(`Kayan "${kayan.name}" row${id} is stored with a value that is not of its kind in field${fields.length > 1 ? 's' : ''} "${fields.join('", "')}", so it cannot be read. Repair the stored row.`);
202
+ };
203
+ // Masking deletes from the value, so no schema is composed and any validator can be the schema target.
204
+ export const publicResult = async ({ kayan, row, actor, internalOutput, publicOutput, }) => {
205
+ const internal = await storedRow(kayan, internalOutput, row);
206
+ // A custom output is handed the operation's own words beside the proved columns.
207
+ const value = publicOutput ? { ...row, ...internal } : internal;
208
+ const visible = await projectReadableFields({ kayan, row: value, actor });
209
+ // Computed on every read, never stored.
210
+ if (!publicOutput)
211
+ return fileURLs({ kayan, stored: internal, visible });
212
+ const parsed = await validated(publicOutput, visible);
213
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
214
+ throw new Error(`Kayan "${kayan.name}" custom output must return a row object.`);
215
+ }
216
+ const final = await projectReadableFields({ kayan, row: parsed, actor });
217
+ const removed = Object.keys(parsed).filter((key) => !(key in final));
218
+ if (removed.length) {
219
+ throw new Error(`Kayan "${kayan.name}" custom output returned protected field "${removed[0]}" after read projection.`);
220
+ }
221
+ return fileURLs({ kayan, stored: internal, visible: final });
222
+ };
223
+ // An access callback answering `true` puts no tenancy filter on a per-tenant global, so
224
+ // it would read another tenant's row. Refused by name; a `where` admitting several is not checked.
225
+ export const assertSingleConstrained = (kayan, where) => {
226
+ if (kayan.single && kayan.tenant && !where) {
227
+ throw new Error(`Global "${kayan.name}" keeps the tenant field, and the actor's access constrains no rows, so no one row is the tenant's own. Return a Where from its read and update access that constrains the rows to the actor's tenant.`);
228
+ }
229
+ };
230
+ const loadExisting = async (kayan, store, id, where, tx, rows = aliveWhere(kayan)) => {
231
+ assertSingleConstrained(kayan, where);
232
+ const found = await store.find({ kayan: kayan.name, args: {
233
+ limit: 1,
234
+ page: 1,
235
+ lock: 'update',
236
+ // A write's own look at the row it changes, over a set an id or a constraint already reduced.
237
+ sort: { field: 'id', direction: 'asc' },
238
+ // A marked row is not there to change, so a second delete of it is the 404 an absent id gets.
239
+ where: combineWhere(kayan.single ? where : combineWhere({ id: { equals: id } }, where), rows),
240
+ }, tx });
241
+ return found.docs[0];
242
+ };
243
+ // Each related id must be one the target's own read access admits for this actor, and in the
244
+ // written row's organization.
245
+ const validateTenantRelationships = async (kayan, store, data, actor, existing, authorize, admin, tx) => {
246
+ const own = kayan.tenant;
247
+ await assertReferences({
248
+ kayan,
249
+ store,
250
+ data,
251
+ tenant: own ? (data[own] ?? existing?.[own]) : undefined,
252
+ // Asked with no operation, so no permission key is asked for.
253
+ access: (target) => authorize({ admin, actor, data: {}, effect: 'read', kayan: target }),
254
+ tx,
255
+ });
256
+ };
257
+ /**
258
+ * Refuses a relationship value naming a row the write may not point at: one of another
259
+ * organization than the written row's, or one `access` leaves out. A step's own raw write
260
+ * passes no `access`, so it is held to the organization alone.
261
+ */
262
+ export const assertReferences = async ({ kayan, store, data, tenant, access, tx }) => {
263
+ const own = kayan.tenant;
264
+ // Gathered per target, so two fields at one kayan and a whole `hasMany` set are one read.
265
+ const targets = new Map();
266
+ for (const field of Object.values(kayan.fields)) {
267
+ // The ownership column is the runner's, or was judged above. A join takes no write.
268
+ if (!field.target || field.name === own || field.kind === 'join')
269
+ continue;
270
+ const given = data[field.name];
271
+ if (given === undefined || given === null)
272
+ continue;
273
+ for (const value of field.hasMany ? given : [given]) {
274
+ if (value === undefined || value === null)
275
+ continue;
276
+ targets.set(field.target, [...(targets.get(field.target) ?? []), { field: field.name, value }]);
277
+ }
278
+ }
279
+ for (const [target, sets] of targets) {
280
+ const far = target.tenant;
281
+ // A raw write to a shared row names it as it likes; the foreign key holds it.
282
+ if (!access && !far)
283
+ continue;
284
+ // Held whatever the target's own access answers, so no access widens it.
285
+ const tenanted = tenant !== undefined && tenant !== null && far ? { [far]: { equals: tenant } } : undefined;
286
+ // A 400: the value is what the caller sent wrong. Absent, hidden and another tenant's
287
+ // ids are one refusal, so none tells the caller which ids exist.
288
+ const refuse = (field) => {
289
+ const params = { kayan: kayan.name, field, target: target.name };
290
+ return tenanted
291
+ ? invalid({
292
+ message: `"${kayan.name}" field "${field}" must reference a "${target.name}" row in the same tenant.`,
293
+ key: 'error.invalid.tenant_reference',
294
+ params,
295
+ })
296
+ : invalid({
297
+ message: `"${kayan.name}" field "${field}" must reference a "${target.name}" row the actor can read.`,
298
+ key: 'error.invalid.reference',
299
+ params,
300
+ });
301
+ };
302
+ // With no organization and no access to ask, nothing scopes the target, so nothing is admitted.
303
+ if (!access && !tenanted)
304
+ throw refuse(sets[0].field);
305
+ let admits;
306
+ try {
307
+ admits = await access?.(target);
308
+ }
309
+ catch (thrown) {
310
+ if (!(thrown instanceof AsasError) || thrown.status !== 403)
311
+ throw thrown;
312
+ // Fail closed before the store is asked, so the foreign key cannot tell which ids exist.
313
+ throw refuse(sets[0].field);
314
+ }
315
+ // Postgres answers a uuid in lowercase, so ids are compared in that case.
316
+ const uuid = target.fields.id?.kind === 'uuid';
317
+ const keyOf = (value) => (uuid && typeof value === 'string' ? value.toLowerCase() : value);
318
+ const ids = [...new Set(sets.map(({ value }) => keyOf(value)))];
319
+ const found = await store.find({ kayan: target.name, args: {
320
+ limit: ids.length,
321
+ lock: 'update',
322
+ page: 1,
323
+ sort: { field: 'id', direction: 'asc' },
324
+ where: combineWhere(combineWhere(combineWhere({ id: { in: ids } }, admits), tenanted),
325
+ // Nothing a plain read shows may point at a row no plain read shows; a step's own write may.
326
+ access ? publishedWhere(target) : undefined),
327
+ }, tx });
328
+ const admitted = new Set(found.docs.map((row) => keyOf(row.id)));
329
+ const refused = sets.find(({ value }) => !admitted.has(keyOf(value)));
330
+ if (refused)
331
+ throw refuse(refused.field);
332
+ }
333
+ };
334
+ export const defaultAccess = async (admin, actor) => {
335
+ if (!admin?.kayan || !admin.access || !actor || authActor(actor)?.collection !== admin.kayan)
336
+ return false;
337
+ return admin.access({ user: actor });
338
+ };
339
+ // Asked of the one store that grades this actor. The row answers deny first, so this
340
+ // only narrows; a refusal is a 403, never a 5xx an offline client would retry.
341
+ const assertPermission = (kayan, operation, actor) => {
342
+ if (operation === undefined)
343
+ return;
344
+ const keys = heldPermissions({ access: kayan.access, user: actor });
345
+ if (keys === undefined || keys === true)
346
+ return;
347
+ const key = `${kayan.name}.${operation}`;
348
+ if (keys.has(key))
349
+ return;
350
+ throw denied({
351
+ message: `Forbidden: "${kayan.name}" operation "${operation}" needs permission "${key}".`,
352
+ key: 'error.forbidden.permission',
353
+ params: { kayan: kayan.name, operation },
354
+ });
355
+ };
356
+ export const defaultAuthorize = async ({ admin, actor, data, effect, kayan, kind, operation: named, }) => {
357
+ if (effect === 'read') {
358
+ const access = kayan.access?.read;
359
+ const result = access
360
+ ? await access({ id: data.id, slug: kayan.name, user: actor })
361
+ : await defaultAccess(admin, actor);
362
+ if (!result)
363
+ throw denied({ message: `Forbidden: "${kayan.name}" read access denied.`, key: 'error.forbidden.read', params: { kayan: kayan.name } });
364
+ assertPermission(kayan, named, actor);
365
+ return result === true ? undefined : assertWhere(kayan, result);
366
+ }
367
+ const operation = effect;
368
+ const access = kayan.access?.[operation];
369
+ const { id, ...values } = data;
370
+ const accessData = kind === 'insert' ? data : kind === 'update' ? values : undefined;
371
+ const result = access
372
+ ? await access({ data: accessData, id, slug: kayan.name, user: actor })
373
+ : await defaultAccess(admin, actor);
374
+ if (operation === 'create') {
375
+ if (result !== true)
376
+ throw denied({ message: `Forbidden: "${kayan.name}" create access denied.`, key: 'error.forbidden.create', params: { kayan: kayan.name } });
377
+ assertPermission(kayan, named, actor);
378
+ return undefined;
379
+ }
380
+ if (!result)
381
+ throw denied({ message: `Forbidden: "${kayan.name}" ${operation} access denied.`, key: 'error.forbidden.operation', params: { kayan: kayan.name, operation } });
382
+ assertPermission(kayan, named, actor);
383
+ return result === true ? undefined : assertWhere(kayan, result);
384
+ };
385
+ // Shared with the bulk runner, so the depth cap, the cross-transaction refusals and the
386
+ // ambient actor rule answer alike for a bulk and the row it is made of.
387
+ export const beginWrite = ({ kayan, operation, store, cap, options, }) => {
388
+ const outer = running.getStore();
389
+ const chain = [...(outer?.context.chain ?? []), `${kayan.name}.${operation}`];
390
+ const depth = (outer?.context.depth ?? 0) + 1;
391
+ if (depth > cap) {
392
+ throw new Error(`Write depth ${depth} is past the cap of ${cap}: ${chain.join(' -> ')}.`);
393
+ }
394
+ // Frozen, so a step cannot reset `depth` and hand every write under it a spent cap.
395
+ const context = Object.freeze({
396
+ source: 'local',
397
+ ...(outer?.context ?? options?.context),
398
+ depth,
399
+ chain,
400
+ });
401
+ const open = outer?.transactions ?? new Map();
402
+ // A nested write joins the outer transaction on the same store, so both commit or roll
403
+ // back together; another store opens its own. Outside slots carry no transactions.
404
+ const joined = open.get(store);
405
+ if (outer?.transactions && !joined) {
406
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" cannot write to another store while transaction "${outer.context.chain.at(-1)}" is open.`);
407
+ }
408
+ if (joined?.settled) {
409
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" joined the transaction "${joined.operation}" opened, which has already settled, so the write that started this one was not awaited. Await every write a step begins.`);
410
+ }
411
+ const entry = joined ?? { tx: undefined, operation: `${kayan.name}.${operation}` };
412
+ const actor = outer?.user ?? options?.actor ?? null;
413
+ return { actor, context, entry, open };
414
+ };
415
+ // A write from outside the transaction would not roll back with it, or would land after
416
+ // the commit; a step there calls a managed operation, which opens its own.
417
+ const outsideStore = (kayan, operation, slot) => {
418
+ const refuse = () => {
419
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" reached the store from "${slot}", which runs outside the operation's transaction, so the write would not be in it. Write through tools.kayans, which opens its own, or move the step into "steps".`);
420
+ };
421
+ return Object.freeze({ insert: refuse, remove: refuse, update: refuse });
422
+ };
423
+ // No transaction is carried, so a managed write here opens its own. An `afterCommit`
424
+ // throw is never an `AsasError`: the write it would refuse has committed.
425
+ export const runOutside = async ({ kayan, tools, slot, list, start, around, }) => {
426
+ const given = Object.freeze({ ...tools, store: outsideStore(kayan, tools.operation, slot) });
427
+ return running.run(around, async () => {
428
+ let ctx = start;
429
+ try {
430
+ for (const step of list)
431
+ ctx = await step(ctx, given);
432
+ }
433
+ catch (cause) {
434
+ if (slot === 'beforeTransaction')
435
+ throw cause;
436
+ throw new Error(`Kayan "${kayan.name}" operation "${tools.operation}" threw in afterCommit, which runs after the commit, so the write stands.`, { cause });
437
+ }
438
+ return ctx;
439
+ });
440
+ };
441
+ // One that throws does not silence the rest, since each stands for a committed write;
442
+ // the first error is the one reported.
443
+ export const drain = async (queue) => {
444
+ // A wrapper, so a thunk that threw a falsy value is still raised.
445
+ let failure;
446
+ for (const queued of queue) {
447
+ try {
448
+ await queued();
449
+ }
450
+ catch (cause) {
451
+ failure ??= { cause };
452
+ }
453
+ }
454
+ if (failure)
455
+ throw failure.cause;
456
+ };
457
+ // A shape that names the key answers a probe value with an issue at it. An asynchronous
458
+ // answer names nothing, so its key is refused.
459
+ const namesKey = (schema, key) => {
460
+ const answer = schema['~standard'].validate({ [key]: Symbol(key) });
461
+ if (answer instanceof Promise) {
462
+ answer.catch(() => { });
463
+ return false;
464
+ }
465
+ return !!answer.issues?.some(({ path: [first] = [] }) => String(typeof first === 'object' ? first.key : first) === key);
466
+ };
467
+ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, }) => async (data = {}, options) => {
468
+ const list = atEffect(declaration.steps);
469
+ const { actor, context, entry, open } = beginWrite({ kayan, operation: tools.operation, store, cap, options });
470
+ // The id reaches the store before the operation's parse, so it is held to the shape at
471
+ // `id` first: a 400, not the store refusing a value of the wrong type as a 500.
472
+ if (tools.kind !== 'insert' && !kayan.single) {
473
+ const { issues = [] } = await tools.schema['~standard'].validate(withoutPrototype(data));
474
+ const atId = issues.filter(({ path: [first] = [] }) => (typeof first === 'object' ? first.key : first) === 'id');
475
+ if (atId.length)
476
+ throw issueRefusal(atId, tools.schema['~standard'].vendor);
477
+ }
478
+ refuseTenantOption({ kayan, operation: tools.operation, options });
479
+ const owner = writeTenant({ kayan, active: tenantOf(actor), effect: tools.effect, data });
480
+ const versions = kayan.versions;
481
+ // An operation with its own `input` that changes a row, and each bulk row, lands on the
482
+ // row as it is: it keeps `_status` and its columns also go into the pending draft.
483
+ const restoring = list.some((step) => sealOf(step) === 'restoreVersion');
484
+ const onto = !!versions?.drafts && ((!!declaration.input && tools.kind === 'update' && tools.operation !== 'update' && !restoring) || !!options?.[ontoRow]);
485
+ const drafts = !!versions?.drafts && !!options?.draft;
486
+ if (drafts && onto) {
487
+ throw invalid({
488
+ message: `Kayan "${kayan.name}" operation "${tools.operation}" writes its row as it is, so it saves no draft. Call it without draft.`,
489
+ key: 'error.invalid.draft_operation',
490
+ params: { kayan: kayan.name, operation: tools.operation },
491
+ });
492
+ }
493
+ // A draft save answers the whole latest draft, so it asks the key a draft read asks.
494
+ const askDrafts = () => authorize({ admin, actor, data: {}, effect: 'read', kayan, operation: 'findVersions' });
495
+ const draftsAsked = drafts || (!!versions?.drafts && data._status === 'draft');
496
+ // What `findVersions` admits for this actor, kept per row; `undefined` without the key.
497
+ const admits = draftsAsked
498
+ ? { where: await askDrafts() }
499
+ : versions?.drafts && tools.kind !== 'insert'
500
+ ? await askDrafts().then((where) => ({ where }), (thrown) => {
501
+ if (thrown instanceof AsasError && thrown.status === 403)
502
+ return undefined;
503
+ throw thrown;
504
+ })
505
+ : undefined;
506
+ // Past the rows the actor may read drafts of, a never-published row answers the 404 an
507
+ // absent id does: a 403 would tell the actor which hidden ids exist.
508
+ const rowsWhere = !versions?.drafts || tools.kind === 'insert'
509
+ ? aliveWhere(kayan)
510
+ : !admits
511
+ ? publishedWhere(kayan)
512
+ : combineWhere(aliveWhere(kayan), draftsAsked || !admits.where ? admits.where : { or: [{ _status: { equals: 'published' } }, admits.where] });
513
+ // The restore's own key is asked before anything is looked up.
514
+ if (restoring) {
515
+ await authorize({ admin, actor, data: {}, effect: 'update', kayan, kind: 'update', operation: tools.operation });
516
+ const asked = Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'id' && !Object.hasOwn(kayan.fields, key)));
517
+ data = { ...asked, ...await restoreInput({
518
+ kayan: kayan,
519
+ store,
520
+ data,
521
+ actor,
522
+ authorize,
523
+ admin,
524
+ alive: rowsWhere,
525
+ seesDrafts: !!admits,
526
+ admits: admits?.where,
527
+ }) };
528
+ }
529
+ // First-register's own create runs whatever the kayan's create access says, while its table is empty.
530
+ const accessWhere = context.depth === 1 && firstRegisters.has(entry)
531
+ ? undefined
532
+ : await authorize({ admin, actor, data, effect: tools.effect, kayan, kind: tools.kind, operation: tools.operation });
533
+ // The constant that keeps a global to one row, so a caller's value for it is refused by name.
534
+ if (kayan.single && 'globalType' in data) {
535
+ throw denied({
536
+ message: `Forbidden: kayan "${kayan.name}" operation "${tools.operation}" cannot write field "globalType", which is the constant that keeps a global to one row.`,
537
+ key: 'error.forbidden.global_type',
538
+ params: { kayan: kayan.name, operation: tools.operation },
539
+ });
540
+ }
541
+ // Outside the transaction, before it opens: no row is loaded and no lock is held.
542
+ let values = data;
543
+ if (declaration.beforeTransaction.length) {
544
+ const before = await runOutside({
545
+ kayan,
546
+ tools,
547
+ slot: 'beforeTransaction',
548
+ list: atEffect(declaration.beforeTransaction),
549
+ start: { kayan, data: { ...data }, actor, user: actor, tx: undefined, context },
550
+ around: { context, user: actor },
551
+ });
552
+ values = before.data;
553
+ }
554
+ /** The last `ctx` the transaction ended with, which is what `afterCommit` is handed. */
555
+ let committed;
556
+ const run = (tx) => {
557
+ entry.tx = tx;
558
+ return running.run({ context, transactions: new Map(open).set(store, entry), user: actor }, async () => {
559
+ const existing = tools.kind === 'insert' ? undefined : await loadExisting(kayan, store, values.id, accessWhere, tx, rowsWhere);
560
+ // An actor who may not read this row's drafts writes onto it as it is, so nothing tells
561
+ // it a draft is pending.
562
+ const hidesDrafts = !!versions?.drafts && !!existing && (!admits || (!draftsAsked && !!admits.where
563
+ && !(await store.find({ kayan: kayan.name, args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, where: combineWhere({ id: { equals: existing.id } }, admits.where) }, tx })).docs.length));
564
+ const asIs = onto || hidesDrafts;
565
+ // A row found without the constraint means the caller cannot address it (404); none means
566
+ // the upsert. A per-tenant global is left out: the unconstrained look would find another tenant's row.
567
+ if (kayan.single && !kayan.tenant && tools.kind === 'update' && !existing && accessWhere
568
+ && await loadExisting(kayan, store, values.id, undefined, tx)) {
569
+ throw missing({
570
+ message: `No "${kayan.name}" row the access constraint admits.`,
571
+ key: 'error.not_found.global',
572
+ params: { kayan: kayan.name },
573
+ });
574
+ }
575
+ // One word decides the rest of the write: defaults, field callbacks, shape and step.
576
+ const kind = kayan.single && tools.kind === 'update' && !existing ? 'insert' : tools.kind;
577
+ const fieldOperation = kind === 'insert' ? 'create' : kind === 'update' ? 'update' : undefined;
578
+ // The row always holds the last published copy; a create still makes the row, as a draft.
579
+ const drafting = drafts && !!fieldOperation && data._status !== 'published';
580
+ const autosaving = drafting && versions.autosave && !!options?.autosave;
581
+ // A caller who named `_status` is judged by its field access like any column.
582
+ const status = drafting ? 'draft' : data._status ?? (asIs ? existing?._status : undefined) ?? 'published';
583
+ // A change builds on the row's latest version, so a draft keeps the drafts before it and a
584
+ // publish publishes them. Publishing a never-published row brings every column it holds.
585
+ const latest = versions?.drafts && existing
586
+ ? await latestVersion({ kayan: kayan, store, parent: existing.id, tx })
587
+ : undefined;
588
+ // A snapshot's organization is never the row's: the row keeps its own.
589
+ const newest = latest && { ...revived(kayan, latest.version), ...(kayan.tenant && { [kayan.tenant]: existing[kayan.tenant] }) };
590
+ const base = (!restoring && newest) || existing;
591
+ const merged = versions?.drafts && existing && fieldOperation && !restoring && !asIs
592
+ ? { ...changedColumns({ kayan, snapshot: base, row: existing, whole: !drafting && existing._status !== 'published' }), ...values }
593
+ : values;
594
+ // Carried from a draft rather than sent, so field access refuses rather than drops them.
595
+ const carried = merged === values ? [] : Object.keys(merged).filter((key) => !(key in values));
596
+ const pending = asIs && newest && !restoring && base?._status === 'draft' ? base : undefined;
597
+ // A key the operation's own shape would refuse is refused here first, in that shape's words.
598
+ if (drafting) {
599
+ const stray = Object.keys(data).find((key) => key !== 'id' && (Object.hasOwn(kayan.fields, key) ? providerOf(kayan.fields[key], fieldOperation) !== 'client' : !declaration.input || !namesKey(tools.schema, key)));
600
+ if (stray !== undefined)
601
+ throw issueRefusal([{ message: `Unrecognized key: "${stray}"`, path: [stray] }], 'mithaq');
602
+ }
603
+ // What the caller read: the latest version for a writer who reads drafts, so
604
+ // `expectedUpdatedAt` holds a draft save, a publish, a delete and a restore alike.
605
+ const dataBefore = snapshot((!asIs && newest) || existing);
606
+ const owned = kayan.tenant;
607
+ const writeWhere = !owned || existing?.[owned] === undefined
608
+ ? accessWhere
609
+ : combineWhere(accessWhere, { [owned]: { equals: existing[owned] } });
610
+ // A global's update lands on the row that was found, never on an id the caller named.
611
+ const targetId = kayan.single ? existing?.id : values.id;
612
+ // The id is the runner's on both branches, which is why a global's update shape has none.
613
+ const given = kayan.single ? { ...values, id: existing?.id } : merged;
614
+ if (kayan.single && !existing)
615
+ delete given.id;
616
+ // The shipped field carries no default: the value is the global's own name.
617
+ if (kayan.single && !existing)
618
+ given.globalType = kayan.name;
619
+ const inputData = fieldOperation
620
+ ? await fieldAccess(fieldOperation, kayan, given, actor, targetId, tools.operation, existing, carried)
621
+ : given;
622
+ // The audit sink writes through the same three, so only the kayan's own writes save versions.
623
+ const versioned = versions ? kayan : undefined;
624
+ const own = (name) => versioned && name === kayan.name ? versioned : undefined;
625
+ const protectedStore = Object.freeze({
626
+ insert: async ({ tx: heldTx, kayan: name, data: values }) => {
627
+ const row = await tools.store.insert({ tx: heldTx, kayan: name, data: values });
628
+ const kept = own(name);
629
+ if (kept)
630
+ await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row, autosave: autosaving });
631
+ return row;
632
+ },
633
+ update: async ({ tx: heldTx, kayan: name, id, data: values }) => {
634
+ const kept = own(name);
635
+ if (drafting && kept) {
636
+ const draft = { ...base, ...values, id: targetId };
637
+ await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row: draft, autosave: autosaving });
638
+ return draft;
639
+ }
640
+ const row = await tools.store.update({ tx: heldTx, kayan: name, id: targetId ?? id, data: values, where: writeWhere });
641
+ // A never-published row is its own draft, so the draft appended below is its one version.
642
+ if (kept && !(pending && status === 'draft'))
643
+ await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row, autosave: false });
644
+ // Appended after the row's own, so the draft stays the latest.
645
+ if (kept && pending)
646
+ await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row: { ...pending, ...values, id: targetId, _status: 'draft' }, autosave: false });
647
+ return row;
648
+ },
649
+ // A soft delete keeps the `remove` seal, so the effect, the access answer and the 404 stay
650
+ // the delete's; the mark is refused as a field any caller supplies.
651
+ remove: ({ tx: heldTx, kayan: name, id }) => kayan.softDelete
652
+ ? tools.store.update({ tx: heldTx, kayan: name, id: targetId ?? id, data: { [kayan.softDelete]: new Date() }, where: writeWhere })
653
+ : tools.store.remove({ tx: heldTx, kayan: name, id: targetId ?? id, where: writeWhere }),
654
+ });
655
+ // Its absence is a build fault: the partial update shape would make a row without its required fields.
656
+ if (kind !== tools.kind && !tools.insertSchema) {
657
+ throw new Error(`Kayan "${kayan.name}" operation "${tools.operation}" has no insert shape, so the row it would make cannot be parsed.`);
658
+ }
659
+ const insertSchema = tools.insertSchema;
660
+ // Copied, so a step writing `ctx.data` in place never changes the caller's object.
661
+ const runTools = Object.freeze({
662
+ ...tools,
663
+ kind,
664
+ // Read before `defaults` fills, so validation can refuse the caller a key and still let the runner's value through.
665
+ sent: Object.keys(data),
666
+ schema: drafting ? tools.draftInput ?? tools.draftWrite ?? tools.draftSchema : kind === tools.kind ? tools.schema : insertSchema,
667
+ store: protectedStore,
668
+ where: writeWhere,
669
+ });
670
+ let ctx = { kayan, data: { ...inputData }, actor, user: actor, tx, context, dataBefore, dataAfter: dataBefore };
671
+ // With no `input` the filled row is parsed, so a default reaches a required column; with one,
672
+ // the caller's strict shape is parsed first. A remove writes no column, so neither runs.
673
+ const fill = (one) => (fieldOperation ? defaults(one, runTools) : one);
674
+ ctx = declaration.input
675
+ ? fill(await parse({ schema: runTools.schema, ctx, tools: runTools }))
676
+ : await parse({ schema: runTools.schema, ctx: fill(ctx), tools: runTools });
677
+ // Filled after the parse, from the session alone.
678
+ if (kind === 'insert' && kayan.tenant)
679
+ ctx = { ...ctx, data: { ...ctx.data, [kayan.tenant]: owner } };
680
+ // The guards belong to the store seal, and no slot may hold one.
681
+ for (const step of atEffect([
682
+ ...declaration.beforeSteps,
683
+ ...list,
684
+ ...declaration.afterSteps,
685
+ ])) {
686
+ const seal = sealOf(step);
687
+ // A restore's step is the row's own update, so it is guarded as one.
688
+ if (seal && (storeSeals.includes(seal) || seal === 'restoreVersion')) {
689
+ // Reads `kind`, so a global's first update still upserts; at the store seal, after the parse
690
+ // has refused a malformed id.
691
+ if (!existing && kind !== 'insert') {
692
+ throw missing({
693
+ message: `No "${kayan.name}" row with id ${String(values.id)} to ${kind === 'remove' ? 'delete' : 'update'}.`,
694
+ key: 'error.not_found.row',
695
+ params: { kayan: kayan.name, id: values.id, operation: kind === 'remove' ? 'delete' : 'update' },
696
+ });
697
+ }
698
+ if (kind !== 'insert' && ctx.data.id !== targetId) {
699
+ throw new Error(`Kayan "${kayan.name}" operation "${tools.operation}" cannot change the authorized target id.`);
700
+ }
701
+ if (fieldOperation) {
702
+ ctx = { ...ctx, data: await fieldAccess(fieldOperation, kayan, ctx.data, actor, targetId, tools.operation, existing, carried) };
703
+ if (kind !== 'remove')
704
+ await validateTenantRelationships(kayan, store, ctx.data, actor, existing, authorize, admin, tx);
705
+ }
706
+ // A publish holds the row to every `required`, which the partial update shape cannot.
707
+ if (versions?.drafts && status === 'published' && kind === 'update') {
708
+ const unset = Object.values(kayan.fields).filter((field) => field.rules?.required && field.kind !== 'join' && (field.name in ctx.data ? ctx.data[field.name] : existing?.[field.name]) == null);
709
+ if (unset.length)
710
+ throw issueRefusal(unset.map((field) => ({ message: `${kayan.name}.${field.name}.required`, path: [field.name] })), 'mithaq');
711
+ // A never-published row's create-only columns are asked of the create's field access and
712
+ // parsed against the create shape.
713
+ if (existing._status !== 'published') {
714
+ const left = { ...existing, ...ctx.data };
715
+ const made = Object.values(kayan.fields).filter((field) => field.kind !== 'join' && providerOf(field, 'create') === 'client' && left[field.name] != null);
716
+ const createOnly = made.filter((field) => providerOf(field, 'update') !== 'client' && !(field.name in ctx.data)).map((field) => field.name);
717
+ await fieldAccess('create', kayan, Object.fromEntries(createOnly.map((name) => [name, left[name]])), actor, targetId, tools.operation, existing, createOnly);
718
+ await validated(insertSchema, Object.fromEntries(made.map((field) => [field.name, left[field.name]])));
719
+ }
720
+ }
721
+ // A step that wrote a `never` field is the project contradicting its own declaration,
722
+ // so a plain `Error`, never an `AsasError` blaming the caller, who cannot reach here.
723
+ if (fieldOperation) {
724
+ const written = Object.values(kayan.fields).find((field) => providerOf(field, fieldOperation) === 'never' && field.name in ctx.data);
725
+ if (written) {
726
+ throw new Error(`Kayan "${kayan.name}" operation "${tools.operation}" wrote field "${written.name}", which declares providedBy "never" on ${fieldOperation}, so nothing supplies it.`);
727
+ }
728
+ }
729
+ // Written after the field callbacks: publishing is what a write without `draft` means,
730
+ // not a column a role must hold a key for.
731
+ if (versions?.drafts && fieldOperation)
732
+ ctx = { ...ctx, data: { ...ctx.data, _status: status } };
733
+ // After the field callbacks, which deny the field's own update and would refuse the
734
+ // runner's value; on insert no create callback would refuse a step's.
735
+ if (kayan.single)
736
+ ctx = { ...ctx, data: { ...ctx.data, globalType: kayan.name } };
737
+ }
738
+ ctx = await step(ctx, runTools);
739
+ }
740
+ committed = ctx;
741
+ // A draft may be short of `required`, so it leaves through the draft shape, which holds it to its kinds alone.
742
+ const row = ctx.data;
743
+ return publicResult({
744
+ kayan,
745
+ row,
746
+ actor,
747
+ internalOutput: drafting || (tools.draftSchema && row._status === 'draft') ? tools.draftSchema : tools.output,
748
+ publicOutput: tools.publicOutput,
749
+ });
750
+ });
751
+ };
752
+ // A throw here is `AfterCommitError`: the write has happened, and nothing the caller sent was wrong.
753
+ const afterCommit = async () => {
754
+ if (!declaration.afterCommit.length || !committed)
755
+ return;
756
+ await runOutside({
757
+ kayan,
758
+ tools,
759
+ slot: 'afterCommit',
760
+ list: atEffect(declaration.afterCommit),
761
+ start: { ...committed, tx: undefined },
762
+ around: { context, user: actor },
763
+ });
764
+ };
765
+ const joined = open.get(store);
766
+ // Drained by the write that opened the transaction; an outer rollback throws before the drain.
767
+ if (joined) {
768
+ const result = await run(joined.tx);
769
+ if (declaration.afterCommit.length)
770
+ joined.afterCommit = [...(joined.afterCommit ?? []), afterCommit];
771
+ return result;
772
+ }
773
+ let result;
774
+ try {
775
+ result = await store.transaction(run);
776
+ }
777
+ finally {
778
+ entry.settled = true;
779
+ }
780
+ try {
781
+ await drain([...(entry.afterCommit ?? []), afterCommit]);
782
+ }
783
+ catch (cause) {
784
+ // Only the outermost operation answers with what it committed; under an outer one the
785
+ // failure climbs as a plain `Error`.
786
+ if (running.getStore())
787
+ throw cause;
788
+ throw new AfterCommitError(result, cause);
789
+ }
790
+ return result;
791
+ };