@drhomet/mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +962 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1056 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -3,6 +3,7 @@ import { declaredFieldAccess } from '../../mithaq/rows.js';
3
3
  import { providerOf } from '../../base/rules.js';
4
4
  import { invalid } from './errors.js';
5
5
  import { snapshot } from './runtime.js';
6
+ import { rawStepStore } from './store.js';
6
7
  // The assertion checks the list stays inside `OperationStep<never>`, not the direction it names.
7
8
  export const atEffect = (list) => list;
8
9
  // A map only this module writes: a tag on the function would let any function buy
@@ -17,6 +18,12 @@ const seal = (name, step) => {
17
18
  };
18
19
  /** What position this step holds, or nothing at all for a step of the app's own. */
19
20
  export const sealOf = (step) => seals.get(step);
21
+ // Each copy of mithaq in the process lists its seals here, so a step a second install sealed is
22
+ // named rather than miscounted. Anything can write this map, so its answer only ever refuses.
23
+ const copies = (globalThis[Symbol.for('mithaq.copies')] ??= new Map());
24
+ copies.set(import.meta.url, (step) => seals.get(step));
25
+ /** Where the other copy of mithaq that sealed this step was loaded from, if one did. */
26
+ export const foreignCopyOf = (step) => [...copies].find(([url, of]) => url !== import.meta.url && of(step))?.[0];
20
27
  /** Hold an operation's `output` function until `build` has the derived shapes. */
21
28
  export const holdOutput = (operation, of) => {
22
29
  outputs.set(operation, of);
@@ -51,7 +58,7 @@ export const audit = seal('audit', async (ctx, tools) => {
51
58
  operation: tools.operation,
52
59
  dataBefore: withoutSecrets(ctx.kayan, ctx.dataBefore),
53
60
  dataAfter: withoutSecrets(ctx.kayan, ctx.dataAfter ?? snapshot(ctx.data) ?? ctx.data),
54
- store: 'store' in tools ? tools.store : undefined,
61
+ store: 'store' in tools ? rawStepStore(tools.store) : undefined,
55
62
  tx: ctx.tx,
56
63
  user: ctx.user,
57
64
  });
@@ -60,9 +67,9 @@ export const audit = seal('audit', async (ctx, tools) => {
60
67
  // A row's filter and a default read the same reference off the same actor, so they agree.
61
68
  const defaultOf = (field, ctx, tools) => {
62
69
  const value = field.rules?.defaultValue;
63
- // Copied, so a step pushing onto one row's list never changes the next row's default.
70
+ // Copied whole, so a step changing one row's list or object never changes the next row's default.
64
71
  if (!isRef(value))
65
- return Array.isArray(value) ? [...value] : value;
72
+ return typeof value === 'object' && value !== null ? structuredClone(value) : value;
66
73
  if (!ctx.user) {
67
74
  throw new Error(`Kayan "${ctx.kayan.name}" operation "${tools.operation}" field "${field.name}" defaults to "user.${value.$user}", and this write was given no actor. Pass one: create(data, { actor }).`);
68
75
  }
@@ -151,7 +158,7 @@ export const validate = seal('validate', (() => validate));
151
158
  const columns = (ctx) => Object.fromEntries(Object.entries(ctx.data).filter(([name]) => name in ctx.kayan.fields));
152
159
  /** Make the row, and what came back is what the rest of the list sees. */
153
160
  export const insert = seal('insert', async (ctx, tools) => {
154
- const row = await tools.store.insert({ tx: ctx.tx, kayan: ctx.kayan.name, data: columns(ctx) });
161
+ const row = await tools.store.insert({ tx: ctx.tx, kayan: ctx.kayan, data: columns(ctx) });
155
162
  return { ...ctx, data: { ...ctx.data, ...row }, dataAfter: snapshot(row) };
156
163
  });
157
164
  /** Change the one row the id names; everything else in `data` is the change. An id alone is refused with a 400. */
@@ -168,12 +175,12 @@ export const update = seal('update', async (ctx, tools) => {
168
175
  params: { kayan: ctx.kayan.name, operation: tools.operation },
169
176
  });
170
177
  }
171
- const row = await tools.store.update({ tx: ctx.tx, kayan: ctx.kayan.name, id: ctx.data.id, data: values, where: tools.where });
178
+ const row = await tools.store.update({ tx: ctx.tx, kayan: ctx.kayan, id: ctx.data.id, data: values, where: tools.where });
172
179
  return { ...ctx, data: { ...ctx.data, ...row }, dataAfter: snapshot(row) };
173
180
  });
174
181
  /** Remove the one row the id names, and hand back what was removed. */
175
182
  export const remove = seal('remove', async (ctx, tools) => {
176
- const data = await tools.store.remove({ tx: ctx.tx, kayan: ctx.kayan.name, id: ctx.data.id, where: tools.where });
183
+ const data = await tools.store.remove({ tx: ctx.tx, kayan: ctx.kayan, id: ctx.data.id, where: tools.where });
177
184
  return { ...ctx, data, dataAfter: snapshot(data) };
178
185
  });
179
186
  // The seal and nothing else: `bulk` supplies `tools.many`.
@@ -254,11 +261,10 @@ export const resolveOperations = (declared, slots = {}) => {
254
261
  afterSteps: slots.afterSteps ?? [],
255
262
  afterCommit: slots.afterCommit ?? [],
256
263
  };
257
- const record = typeof declared === 'function' ? declared(given) : declared;
258
- if (!record)
264
+ if (!declared)
259
265
  return {};
260
266
  const resolved = Object.create(null);
261
- for (const [name, declaration] of Object.entries(record)) {
267
+ for (const [name, declaration] of Object.entries(declared)) {
262
268
  if (declaration === undefined)
263
269
  continue;
264
270
  const held = declaration;
@@ -0,0 +1,4 @@
1
+ import type { RawStepStore, StepStore } from '../../mithaq/language.ts';
2
+ import type { RuntimeWriteTools, WriteTools } from './steps.ts';
3
+ export declare const rawStepStore: (store: StepStore) => RawStepStore;
4
+ export declare const stepTools: (tools: RuntimeWriteTools) => WriteTools;
@@ -0,0 +1,21 @@
1
+ import { invalid } from './errors.js';
2
+ // Audit sinks still implement the adapter contract; no raw handle is a property a step can reach.
3
+ const raw = new WeakMap();
4
+ const declaredStore = (store) => {
5
+ const wrapped = Object.freeze(Object.fromEntries(Object.entries(store).map(([word, call]) => [word, (args) => {
6
+ const kayan = args.kayan;
7
+ if (!kayan || typeof kayan !== 'object' || typeof kayan.name !== 'string' || !kayan.fields || typeof kayan.fields !== 'object') {
8
+ throw invalid({ message: 'A step store call names its kayan by its declaration, not a string.', key: 'error.invalid.value' });
9
+ }
10
+ return call({ ...args, kayan: kayan.name });
11
+ }])));
12
+ raw.set(wrapped, store);
13
+ return wrapped;
14
+ };
15
+ export const rawStepStore = (store) => {
16
+ const held = raw.get(store);
17
+ if (!held)
18
+ throw new Error('The audit step needs the store handed to it by the runner.');
19
+ return held;
20
+ };
21
+ export const stepTools = (tools) => Object.freeze({ ...tools, store: declaredStore(tools.store) });
@@ -2,7 +2,7 @@ import { type AssignedResolver } from '../actor.ts';
2
2
  import { type Language, type Store } from '../../mithaq/language.ts';
3
3
  import type { ResolvedKayan } from '../../mithaq/types.ts';
4
4
  import { type Where } from '../../mithaq/where.ts';
5
- import type { OperationEffect, WriteTools } from './steps.ts';
5
+ import type { OperationEffect, RuntimeWriteTools as WriteTools } from './steps.ts';
6
6
  import { type Authorize } from './write.ts';
7
7
  /** How far a read reaches past the active organization: every verified membership, or every tenant. */
8
8
  export type TenantReach = 'mine' | 'all';
@@ -47,7 +47,7 @@ export declare const tenancy: ({ language, assigned, onError, collection }: {
47
47
  scoped: (inner: Authorize) => Authorize;
48
48
  active: (actor: unknown) => string | undefined;
49
49
  sealWrites: ({ writes, find, kayanOf }: {
50
- writes: WriteTools['store'];
50
+ writes: Omit<WriteTools['store'], 'hold'>;
51
51
  find: Store['find'];
52
52
  kayanOf: (name: string) => ResolvedKayan | undefined;
53
53
  }) => WriteTools['store'];
@@ -2,10 +2,10 @@
2
2
  // access answers, so no row table, callback or authorizer can widen it.
3
3
  import { authActor, markAuthActor, resolveAssigned } from '../actor.js';
4
4
  import { isTenant } from '../../mithaq/language.js';
5
- import { combineWhere, same } from '../../mithaq/where.js';
6
- import { AsasError, denied, invalid } from './errors.js';
5
+ import { combineWhere, compareValues, same } from '../../mithaq/where.js';
6
+ import { AsasError, denied, invalid, missing } from './errors.js';
7
7
  import { running } from './runtime.js';
8
- import { assertReferences } from './write.js';
8
+ import { assertStoreWhere, assertReferences } from './write.js';
9
9
  // The session must come from the one Better Auth kayan of the build, whose organizations the
10
10
  // tenant kayan holds: another instance's organization ids name no tenant here.
11
11
  const sessionOf = ({ actor, collection }) => {
@@ -89,8 +89,8 @@ export const tenancy = ({ language, assigned, onError, collection }) => {
89
89
  }
90
90
  if (reach === 'all') {
91
91
  const trusted = authActor(actor);
92
- if (!trusted || !target.platform || (await target.platform({ user: actor })) !== true) {
93
- throw refused('tenant "all" reads every organization, which only platform staff in the platform app may.');
92
+ if (!trusted || !target.backoffice || (await target.backoffice({ user: actor })) !== true) {
93
+ throw refused('tenant "all" reads every organization, which only backoffice staff in the backoffice app may.');
94
94
  }
95
95
  return { reach };
96
96
  }
@@ -173,13 +173,34 @@ export const tenancy = ({ language, assigned, onError, collection }) => {
173
173
  * organization's delete never reaches it.
174
174
  */
175
175
  const sealWrites = ({ writes, find, kayanOf }) => {
176
- const references = async ({ name, data, tx }) => {
176
+ // A versions table or a join table holds no tenant field to seal by, so a step reaches neither.
177
+ const named = (name) => {
177
178
  const kayan = kayanOf(name);
178
- if (kayan)
179
- await assertReferences({ kayan, store: { find }, data, tenant: active(running.getStore()?.user), tx });
179
+ if (!kayan)
180
+ throw new Error(`A step wrote "${name}", which names no kayan, and a step writes a kayan's rows alone: versions and join rows are core's.`);
181
+ return kayan;
182
+ };
183
+ const declaredField = (name, key) => {
184
+ if (!Object.hasOwn(named(name).fields, key))
185
+ throw invalid({ message: `Kayan "${name}" declares no field "${key}".`, key: 'error.invalid.value', params: { kayan: name, field: key } });
186
+ };
187
+ const checked = (name, where) => {
188
+ if (!where)
189
+ return where;
190
+ try {
191
+ return assertStoreWhere(named(name), where);
192
+ }
193
+ catch (cause) {
194
+ throw invalid({ message: cause instanceof Error ? cause.message : 'Invalid store filter.', key: 'error.invalid.value', params: { kayan: name }, cause });
195
+ }
196
+ };
197
+ const references = async ({ name, data, tx }) => {
198
+ for (const key of Object.keys(data))
199
+ declaredField(name, key);
200
+ return assertReferences({ kayan: named(name), store: { find }, data, tenant: active(running.getStore()?.user), tx });
180
201
  };
181
202
  const sealed = (name, data) => {
182
- const field = kayanOf(name)?.tenant;
203
+ const field = named(name).tenant;
183
204
  if (!field)
184
205
  return undefined;
185
206
  const org = active(running.getStore()?.user);
@@ -189,24 +210,65 @@ export const tenancy = ({ language, assigned, onError, collection }) => {
189
210
  throw new Error(`A step wrote field "${field}" of kayan "${name}", and a row is made in its organization by the runner alone and never leaves it.`);
190
211
  return { [field]: { equals: org } };
191
212
  };
213
+ const sealedFind = async (args) => {
214
+ if (args.args.lock && !args.tx)
215
+ throw new Error('Holding rows requires a transaction.');
216
+ declaredField(args.kayan, args.args.sort.field);
217
+ return find({ ...args, args: { ...args.args, where: combineWhere(checked(args.kayan, args.args.where), sealed(args.kayan)) } });
218
+ };
192
219
  return Object.freeze({
220
+ find: sealedFind,
221
+ sum: async (args) => {
222
+ declaredField(args.kayan, args.field);
223
+ return writes.sum({ ...args, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) });
224
+ },
225
+ hold: async ({ tx, kayan, ids, mode }) => {
226
+ if (!['update', 'noKeyUpdate', 'share', 'keyShare'].includes(mode))
227
+ throw invalid({ message: 'Holding rows requires a valid lock mode.', key: 'error.invalid.value' });
228
+ if (!tx)
229
+ throw new Error('Holding rows requires a transaction.');
230
+ const uuid = named(kayan).fields.id?.kind === 'uuid';
231
+ const ordered = [...new Set(ids.map((id) => uuid && typeof id === 'string' ? id.toLowerCase() : id))].sort((left, right) => compareValues({ left, right, direction: 'asc' }));
232
+ for (const id of ordered) {
233
+ const { docs } = await sealedFind({ tx, kayan, args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, lock: mode, where: { id: { equals: id } } } });
234
+ if (!docs.length)
235
+ throw missing({ message: `No "${kayan}" row found.`, key: 'error.not_found.row', params: { kayan } });
236
+ }
237
+ },
238
+ upsert: async (args) => {
239
+ const field = named(args.kayan).tenant;
240
+ const org = active(running.getStore()?.user);
241
+ if (field && (org === undefined || (Object.hasOwn(args.data, field) && args.data[field] !== org))) {
242
+ throw new Error(`A step upserted kayan "${args.kayan}" in another organization than the active one.`);
243
+ }
244
+ const data = field ? { ...args.data, [field]: org } : args.data;
245
+ const kayan = named(args.kayan);
246
+ if (!args.on.length || new Set(args.on).size !== args.on.length || !(args.on.length === 1 && args.on[0] === 'id') && !kayan.indexes.some((index) => index.unique && index.fields.length === args.on.length && index.fields.every((field) => args.on.includes(field))) || args.on.some((key) => !Object.hasOwn(data, key) || (data[key] && typeof data[key] === 'object' && '$inc' in data[key]))) {
247
+ throw invalid({ message: `Kayan "${args.kayan}" upsert needs plain key values for a declared unique index in "on".`, key: 'error.invalid.value', params: { kayan: args.kayan } });
248
+ }
249
+ for (const key of args.on)
250
+ declaredField(args.kayan, key);
251
+ await references({ name: args.kayan, data, tx: args.tx });
252
+ return writes.upsert({ ...args, data, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) });
253
+ },
193
254
  insert: async (args) => {
194
- const field = kayanOf(args.kayan)?.tenant;
255
+ const field = named(args.kayan).tenant;
256
+ const org = active(running.getStore()?.user);
195
257
  if (field) {
196
- const org = active(running.getStore()?.user);
197
- if (org === undefined || args.data[field] !== org) {
258
+ if (org === undefined || (Object.hasOwn(args.data, field) && args.data[field] !== org)) {
198
259
  throw new Error(`A step made a row of kayan "${args.kayan}" in another organization than the active one, and a row is made in its own organization alone.`);
199
260
  }
200
261
  }
201
- await references({ name: args.kayan, data: args.data, tx: args.tx });
202
- return writes.insert(args);
262
+ const data = field ? { ...args.data, [field]: org } : args.data;
263
+ await references({ name: args.kayan, data, tx: args.tx });
264
+ return writes.insert({ ...args, data });
203
265
  },
204
266
  update: async (args) => {
205
- const where = combineWhere(args.where, sealed(args.kayan, args.data));
267
+ const where = combineWhere(checked(args.kayan, args.where), sealed(args.kayan, args.data));
206
268
  await references({ name: args.kayan, data: args.data, tx: args.tx });
207
269
  return writes.update({ ...args, where });
208
270
  },
209
- remove: (args) => writes.remove({ ...args, where: combineWhere(args.where, sealed(args.kayan)) }),
271
+ remove: async (args) => writes.remove({ ...args, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) }),
210
272
  });
211
273
  };
212
274
  return { open, scoped, active, sealWrites };
@@ -1,15 +1,15 @@
1
- import type { AdminAccess, LocalesOf, ResolvedKayan, RuntimeField, ServiceOf } from '../../mithaq/types.ts';
1
+ import type { AdminAccess, LocalesOf, ResolvedField, ResolvedKayan, RuntimeField, ServiceOf } from '../../mithaq/types.ts';
2
2
  import type { BuiltTables } from '../../drizzle/index.ts';
3
3
  import { defaultKinds, type Kind } from '../../base/kinds.ts';
4
- import type { RichTextValue } from '../../rich-text/rich-text.ts';
4
+ import type { RichTextValue } from '../../rich-text/value.ts';
5
5
  import type { Store } from '../../mithaq/language.ts';
6
6
  import type { LanguageOf } from '../../mithaq/define.ts';
7
7
  import type { InferInput, InferOutput, StandardSchemaV1 } from '../../mithaq/standard-schema.ts';
8
- import { type BulkResult, type Context, type ContextOf, type Ctx, type DerivedSchemas, type OperationEffect, type ResolvedOperation, type StepsOf, type Tools, type WriteTools, type WriteStep } from './steps.ts';
8
+ import { type BulkResult, type Context, type ContextOf, type Ctx, type DerivedSchemas, type OperationEffect, type ResolvedOperation, type StepsOf, type Tools, type RuntimeWriteTools as WriteTools, type WriteStep } from './steps.ts';
9
9
  import { type Open } from './runtime.ts';
10
10
  import type { FindInput } from './read.ts';
11
11
  import { type TenantReach } from './tenant.ts';
12
- import type { FieldWhere, Operator, Where } from '../../mithaq/where.ts';
12
+ import type { FieldWhere, Operator, Scalar, Where } from '../../mithaq/where.ts';
13
13
  /** The key of `Options` only the bulk runner sets. */
14
14
  export declare const ontoRow: unique symbol;
15
15
  /** The caller's own context, required where the project declared one. */
@@ -32,7 +32,7 @@ export type ReadOptions<Actor = unknown, Extra extends Record<string, unknown> =
32
32
  /**
33
33
  * How far the read reaches past the active organization: `'mine'` is every organization the
34
34
  * actor is a verified member of, each under the roles it holds there; `'all'` is every tenant,
35
- * for platform staff in the platform app alone. A `where` on the tenant field only narrows it.
35
+ * for backoffice staff in the backoffice app alone. A `where` on the tenant field only narrows it.
36
36
  */
37
37
  tenant?: TenantReach;
38
38
  };
@@ -49,6 +49,8 @@ export type ManagedAdmin<Actor = unknown> = {
49
49
  access?: AdminAccess<Actor>;
50
50
  kayan?: string;
51
51
  };
52
+ /** Raw store filters keep their adapter's value and empty-filter semantics; only declared paths are checked here. */
53
+ export declare const assertStoreWhere: (kayan: ResolvedKayan, where: Where) => Where;
52
54
  export declare const assertFieldWhere: ({ said, key, kind, operators, value, valued }: {
53
55
  said: string;
54
56
  key: string;
@@ -59,8 +61,37 @@ export declare const assertFieldWhere: ({ said, key, kind, operators, value, val
59
61
  }) => FieldWhere;
60
62
  export declare const assertWhere: (kayan: ResolvedKayan, where: unknown) => Where;
61
63
  export declare const assertWhereGrammar: (kayan: ResolvedKayan, where: unknown) => Where;
64
+ /**
65
+ * How large a caller's `where` may be: the values one `in` or `not_in` lists, and the fields, `and`s and
66
+ * `or`s it holds in all, however nested. Each is a condition the store runs, so past these a read is
67
+ * refused before it reaches the store rather than holding it for minutes.
68
+ */
69
+ export declare const whereLimits: {
70
+ readonly values: 1000;
71
+ readonly nodes: 200;
72
+ };
62
73
  export declare const aliveWhere: (kayan: ResolvedKayan) => Where | undefined;
63
74
  export declare const publishedWhere: (kayan: ResolvedKayan) => Where | undefined;
75
+ /** A field of one kayan that points at rows of another, which a delete of one of those rows answers to. */
76
+ export type Pointer = {
77
+ kayan: ResolvedKayan;
78
+ field: ResolvedField;
79
+ };
80
+ /** Every field of `kayans` whose `onDelete` keeps a row of `target` while it names it. */
81
+ export declare const restrictingPointers: (kayans: readonly ResolvedKayan[], target: ResolvedKayan) => Pointer[];
82
+ /**
83
+ * The ids of the rows of each kayan that name `id` through `pointers`, a row whose latest version
84
+ * names it among them, since the foreign key holds live rows alone and a pending draft would
85
+ * otherwise keep an id nothing answers for. A pointing kayan that is tenant-owned is read within
86
+ * `tenant` when one is given, so a count never reaches another organization's rows.
87
+ */
88
+ export declare const referencingIds: ({ id, pointers, store, tenant, tx }: {
89
+ id: unknown;
90
+ pointers: readonly Pointer[];
91
+ store: Pick<Store, 'find'>;
92
+ tenant?: unknown;
93
+ tx?: unknown;
94
+ }) => Promise<Map<ResolvedKayan, Set<string>>>;
64
95
  export declare const projectReadableFields: <Actor>({ kayan, row, actor, }: {
65
96
  kayan: ResolvedKayan;
66
97
  row: Record<string, unknown>;
@@ -77,11 +108,24 @@ export declare const publicResult: <Actor>({ kayan, row, actor, internalOutput,
77
108
  }) => Promise<Record<string, unknown>>;
78
109
  export declare const assertSingleConstrained: (kayan: ResolvedKayan, where: Where | undefined) => void;
79
110
  /**
80
- * Refuses a relationship value naming a row the write may not point at: one of another
81
- * organization than the written row's, or one `access` leaves out. A step's own raw write
82
- * passes no `access`, so it is held to the organization alone.
111
+ * The lock a write holds on a row it changes, as Postgres's own statement takes it: `FOR UPDATE` for a
112
+ * delete, or for an update naming a column of a unique index, which holds off a write naming the row
113
+ * `FOR KEY SHARE`, and `FOR NO KEY UPDATE` for any other update, which that write passes. A soft delete
114
+ * is an update of its mark, and asks no rows naming its row, so it has no reference to hold off. Taken
115
+ * whole when the row loads, before any value is compared: an UPDATE raising its lock later would wait on
116
+ * a writer that named the row in between while that writer waits on it, a deadlock.
117
+ *
118
+ * ponytail: publishing a latest draft's unique column can still raise this lock; take `update` for every
119
+ * non-draft update on a kayan with drafts and a unique index if that path needs to be covered.
120
+ * ponytail: an update step can raise its own row's lock by writing a unique column or hard-deleting the
121
+ * row; take `update` at load for an operation whose steps may do either if that path needs to be covered.
83
122
  */
84
- export declare const assertReferences: ({ kayan, store, data, tenant, access, tx }: {
123
+ export declare const rowLock: ({ kayan, kind, data }: {
124
+ kayan: ResolvedKayan;
125
+ kind: string;
126
+ data: Record<string, unknown>;
127
+ }) => "noKeyUpdate" | "update";
128
+ export declare const assertReferences: ({ kayan, store, data, tenant, access, offered, tx }: {
85
129
  kayan: ResolvedKayan;
86
130
  store: Pick<Store, 'find'>;
87
131
  data: Record<string, unknown>;
@@ -89,6 +133,8 @@ export declare const assertReferences: ({ kayan, store, data, tenant, access, tx
89
133
  tenant: unknown;
90
134
  /** What else the target's row must meet: its read access for this actor. */
91
135
  access?: (target: ResolvedKayan) => Promise<Where | undefined>;
136
+ /** What one field's `filterOptions` offers this actor, which a managed write is held to. */
137
+ offered?: (field: ResolvedField) => Promise<Where | undefined>;
92
138
  tx?: unknown;
93
139
  }) => Promise<void>;
94
140
  export declare const defaultAccess: <Actor>(admin: ManagedAdmin<Actor> | undefined, actor: Actor | null) => Promise<boolean>;
@@ -126,7 +172,7 @@ export declare const runOutside: <Actor>({ kayan, tools, slot, list, start, arou
126
172
  };
127
173
  }) => Promise<Ctx>;
128
174
  export declare const drain: (queue: readonly (() => Promise<void>)[]) => Promise<void>;
129
- export declare const write: <Actor = unknown>({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, }: {
175
+ export declare const write: <Actor = unknown>({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, pointers, }: {
130
176
  kayan: ResolvedKayan;
131
177
  declaration: ResolvedOperation;
132
178
  tools: WriteTools<unknown, Actor>;
@@ -136,6 +182,8 @@ export declare const write: <Actor = unknown>({ kayan, declaration, tools, store
136
182
  admin?: ManagedAdmin<Actor>;
137
183
  /** The organization the actor's session names, which a tenant-owned row is made in. */
138
184
  tenantOf: (actor: unknown) => string | undefined;
185
+ /** The fields that keep a row of this kayan while they name it, which a delete answers to. */
186
+ pointers?: readonly Pointer[];
139
187
  }) => (data?: Record<string, unknown>, options?: Options<Actor>) => Promise<Record<string, unknown>>;
140
188
  type Registry = readonly {
141
189
  name: string;
@@ -354,6 +402,120 @@ type InputOf<K, Name extends PropertyKey, Fields extends Record<string, RuntimeF
354
402
  type BoundOne<K, Fields extends Record<string, RuntimeField>, L extends LanguageValues, List extends Registry, Mode extends 'read' | 'write'> = {
355
403
  [Name in keyof OwnOperations<K> as Mode extends 'write' ? Name : IsReadOperation<OwnOperation<K, Name>> extends true ? Name : never]: (...input: {} extends InputOf<K, Name, Fields, L, List> ? [input?: InputOf<K, Name, Fields, L, List>] : [input: InputOf<K, Name, Fields, L, List>]) => OperationResult<OwnOperation<K, Name>, Fields, L, List>;
356
404
  };
405
+ type StoreFields<K extends Registry[number]> = {
406
+ [N in keyof K['fields'] as K['fields'][N] extends {
407
+ kind: 'join';
408
+ } ? never : N]: K['fields'][N];
409
+ };
410
+ type StoreNames<K extends Registry[number]> = keyof StoreFields<K> & string;
411
+ type StoreNumeric<K extends Registry[number]> = string extends StoreNames<K> ? string : {
412
+ [N in StoreNames<K>]: K['fields'][N] extends {
413
+ kind: 'integer' | 'number' | 'decimal';
414
+ } ? N : never;
415
+ }[StoreNames<K>];
416
+ type StoreValue<K extends Registry[number], F> = ([Value<F, LanguageOf<K>, readonly [K]>] extends [never] ? F extends {
417
+ relationTo: string;
418
+ } ? F extends {
419
+ hasMany: true;
420
+ } ? (string | number)[] : string | number : never : Value<F, LanguageOf<K>, readonly [K]>) | (F extends {
421
+ rules: {
422
+ required: true;
423
+ };
424
+ } ? never : null);
425
+ type StoreData<K extends Registry[number], Increment extends boolean = false> = string extends StoreNames<K> ? Record<string, unknown> : {
426
+ [N in StoreNames<K>]?: (N extends 'id' ? StoreID<K> : StoreValue<K, K['fields'][N]>) | (Increment extends true ? K['fields'][N] extends {
427
+ kind: 'integer';
428
+ } ? {
429
+ $inc: number;
430
+ } : never : never);
431
+ };
432
+ type StorePath<K extends Registry[number]> = StoreNames<K> | {
433
+ [N in StoreNames<K>]: K['fields'][N] extends {
434
+ rules: {
435
+ localized: true;
436
+ };
437
+ } ? `${N}.${LocalesOf<LanguageOf<K>>}` : K['fields'][N] extends {
438
+ relationTo: () => infer Related extends Registry[number];
439
+ } ? `${N}.${keyof Related['fields'] & string}` : K['fields'][N] extends {
440
+ kind: 'json';
441
+ } ? `${N}.${string}` : never;
442
+ }[StoreNames<K>];
443
+ type StorePathValue<K extends Registry[number], Path extends string> = Path extends keyof K['fields'] ? StoreValue<K, K['fields'][Path]> : Path extends `${infer Name}.${infer Rest}` ? Name extends keyof K['fields'] ? K['fields'][Name] extends {
444
+ relationTo: () => infer Related extends Registry[number];
445
+ } ? Rest extends keyof Related['fields'] ? StoreValue<Related, Related['fields'][Rest]> : never : K['fields'][Name] extends {
446
+ rules: {
447
+ localized: true;
448
+ };
449
+ } ? KindCarried<K['fields'][Name], LanguageOf<K>, readonly [K]> | null : Scalar : never : never;
450
+ type StoreCondition<Value> = {
451
+ [O in Operator]?: O extends 'exists' ? boolean : O extends 'in' | 'not_in' ? readonly Value[] : O extends 'like' ? Extract<Value, string> : O extends 'contains' ? Extract<Value, string> | (Value extends readonly (infer Item)[] ? Item : never) : Value;
452
+ };
453
+ type StoreWhere<K extends Registry[number]> = string extends StoreNames<K> ? Where : {
454
+ [P in StorePath<K>]?: StoreCondition<StorePathValue<K, P>>;
455
+ } & {
456
+ and?: readonly StoreWhere<K>[];
457
+ or?: readonly StoreWhere<K>[];
458
+ };
459
+ type StoreResult<K extends Registry[number]> = StoredRow<StoreFields<K>, LanguageOf<K>, readonly [K]>;
460
+ type StoreID<K extends Registry[number]> = string extends StoreNames<K> ? unknown : Exclude<Value<K['fields']['id'], LanguageOf<K>, readonly [K]>, null | undefined>;
461
+ type StoreLock = NonNullable<Parameters<Store['find']>[0]['args']['lock']>;
462
+ /** The declaration carries the language and fields, so a step needs no separate name registry. */
463
+ export type DeclarationStore = {
464
+ insert<const K extends Registry[number]>(args: {
465
+ tx: unknown;
466
+ kayan: K;
467
+ data: StoreData<NoInfer<K>>;
468
+ }): Promise<StoreResult<K>>;
469
+ update<const K extends Registry[number]>(args: {
470
+ tx: unknown;
471
+ kayan: K;
472
+ id: StoreID<NoInfer<K>>;
473
+ data: StoreData<NoInfer<K>, true>;
474
+ where?: StoreWhere<NoInfer<K>>;
475
+ }): Promise<StoreResult<K>>;
476
+ upsert<const K extends Registry[number]>(args: {
477
+ tx: unknown;
478
+ kayan: K;
479
+ on: readonly StoreNames<NoInfer<K>>[];
480
+ data: StoreData<NoInfer<K>, true>;
481
+ where?: StoreWhere<NoInfer<K>>;
482
+ }): Promise<StoreResult<K>>;
483
+ remove<const K extends Registry[number]>(args: {
484
+ tx: unknown;
485
+ kayan: K;
486
+ id: StoreID<NoInfer<K>>;
487
+ where?: StoreWhere<NoInfer<K>>;
488
+ }): Promise<StoreResult<K>>;
489
+ find<const K extends Registry[number]>(args: {
490
+ tx?: unknown;
491
+ kayan: K;
492
+ args: {
493
+ limit: number;
494
+ page: number;
495
+ sort: {
496
+ field: StoreNames<NoInfer<K>>;
497
+ direction: 'asc' | 'desc';
498
+ };
499
+ lock?: StoreLock;
500
+ where?: StoreWhere<NoInfer<K>>;
501
+ };
502
+ }): Promise<{
503
+ docs: StoreResult<K>[];
504
+ totalDocs: number;
505
+ }>;
506
+ hold<const K extends Registry[number]>(args: {
507
+ tx: unknown;
508
+ kayan: K;
509
+ ids: readonly StoreID<NoInfer<K>>[];
510
+ mode: StoreLock;
511
+ }): Promise<void>;
512
+ sum<const K extends Registry[number]>(args: {
513
+ tx?: unknown;
514
+ kayan: K;
515
+ field: StoreNumeric<NoInfer<K>>;
516
+ where?: StoreWhere<NoInfer<K>>;
517
+ }): Promise<string>;
518
+ };
357
519
  export type BoundOf<Mode extends 'read' | 'write'> = <const K extends Registry[number] & {
358
520
  single?: never;
359
521
  }>(kayan: K) => BoundOne<K, K['fields'], LanguageOf<K>, readonly [K], Mode>;
@@ -412,7 +574,7 @@ type GlobalOperations<K extends Registry[number], List extends Registry, L exten
412
574
  findOne(...args: CallArgs<L, Actor, {
413
575
  depth?: number;
414
576
  }>): Promise<Row<K['fields'], L, List> | null>;
415
- update(...args: CallArgs<L, Actor, UpdateInput<K['fields'], L, List>>): Promise<Row<K['fields'], L, List>>;
577
+ update(...args: CallArgs<L, Actor, Omit<OperationData<OwnOperation<K, 'update'>, UpdateInput<K['fields'], L, List>>, 'id'>>): Promise<Row<K['fields'], L, List>>;
416
578
  };
417
579
  /** What `asas.global` holds: one built global per name, of the shape a kayan has. */
418
580
  export type Globals<GlobalList extends Registry, List extends Registry, L extends LanguageValues, Actor = unknown> = {