@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
@@ -4,9 +4,9 @@ import { OpenAPIHandler } from '@orpc/openapi/fetch';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
5
  import { ORPCError, ValidationError, os } from '@orpc/server';
6
6
  import { RPCHandler } from '@orpc/server/fetch';
7
- import { AfterCommitError, AsasError, afterCommitHeader, codes } from '../runner/errors.js';
7
+ import { AfterCommitError, AsasError, afterCommitHeader, codes, retryConflict } from '../runner/errors.js';
8
8
  import { issueRefusal, withoutPrototype } from '../runner/steps.js';
9
- import { decodeJson, draftOptions } from './fetch.js';
9
+ import { assertMaxBodyBytes, assertOnError, boundedBody, decodeJson, draftOptions } from './fetch.js';
10
10
  // Matched on the `ValidationError` cause, not the shape of `data`, so a host's own
11
11
  // `BAD_REQUEST` keeps its message and data.
12
12
  const boundaryRefusal = (thrown) => thrown instanceof ORPCError && thrown.code === 'BAD_REQUEST' && thrown.cause instanceof ValidationError
@@ -16,120 +16,138 @@ const boundaryRefusal = (thrown) => thrown instanceof ORPCError && thrown.code =
16
16
  * The oRPC plug: one procedure per operation, served over both the typed RPC protocol
17
17
  * and plain HTTP. Needs `@orpc/server` and `@orpc/openapi`.
18
18
  */
19
- export const orpcHttp = ({ rpc = '/api/rpc', api = '/api', context, onError, } = {}) => (operations) => {
20
- // A reporter that throws must not replace the error the host has to see.
21
- const report = (thrown) => {
22
- try {
23
- onError?.(thrown);
24
- }
25
- catch {
26
- // Deliberately empty: reporting may not change what is reported.
27
- }
28
- };
29
- // Per request, because a procedure's handler cannot read the request itself.
30
- const warnings = new AsyncLocalStorage();
31
- // The one place a refusal becomes a wire error, so both handlers share it.
32
- const base = os.$context().use(async ({ next }) => {
33
- try {
34
- return await next();
35
- }
36
- catch (thrown) {
37
- // oRPC validates the bound input inside this middleware, so its own refusal is
38
- // re-emitted in the runner's shape: one shape answers every 400 a procedure raises.
39
- const held = thrown instanceof AsasError ? thrown : boundaryRefusal(thrown);
40
- if (!held) {
41
- report(thrown);
42
- throw thrown;
43
- }
44
- throw new ORPCError(codes[held.status], {
45
- message: held.message,
46
- data: { key: held.key, params: held.params },
47
- // Never serialize the cause: a conflict's driver error names the column and value
48
- // on its `detail`. It stays in process only because oRPC's `toJSON` never emits `cause`.
49
- cause: held,
50
- });
51
- }
52
- });
53
- // No prototype, so a kayan or operation named `constructor` or `__proto__` is an own key.
54
- const router = Object.create(null);
55
- for (const operation of operations) {
56
- const kayan = (router[operation.kayan] ??= Object.create(null));
57
- const input = operation.input;
58
- const zod = input['~standard'].vendor === 'zod';
59
- // The published shape stays for the OpenAPI contract; only validation is skipped,
60
- // because the runner parses every input before any store seal, and a draft against the draft shape.
61
- const wireInput = Object.assign(Object.create(input), {
62
- '~standard': {
63
- ...input['~standard'],
64
- validate: (value, options) => {
65
- const decoded = zod ? withoutPrototype(decodeJson(value, input)) : value;
66
- const object = decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded);
67
- return warnings.getStore()?.options.draft && object ? { value: decoded } : input['~standard'].validate(decoded, options);
68
- },
69
- },
70
- });
71
- kayan[operation.name] = base
72
- .input(wireInput)
73
- .handler(async ({ context: held, input }) => {
19
+ export const orpcHttp = (options) => {
20
+ // Read off `options?`, since a JavaScript caller may hand nothing, and is refused by name.
21
+ assertMaxBodyBytes('orpcHttp', options?.maxBodyBytes);
22
+ assertOnError('orpcHttp', options.onError);
23
+ const { rpc = '/api/rpc', api = '/api', context, onError, maxBodyBytes } = options;
24
+ return (operations) => {
25
+ // A reporter that throws must not replace the error the host has to see.
26
+ const report = (thrown) => {
74
27
  try {
75
- const { actor, ...rest } = held;
76
- const result = await operation.call(input, { actor, context: context ? rest : undefined, ...warnings.getStore()?.options });
77
- const warning = warnings.getStore();
78
- if (warning && operation.effect !== 'read')
79
- warning.said = 'ok';
80
- return result;
28
+ onError(thrown);
81
29
  }
82
- catch (thrown) {
83
- if (!(thrown instanceof AfterCommitError))
84
- throw thrown;
85
- report(thrown.cause);
86
- const warning = warnings.getStore();
87
- if (warning)
88
- warning.said = 'failed';
89
- return thrown.result;
30
+ catch {
31
+ // Deliberately empty: reporting may not change what is reported.
90
32
  }
91
- });
92
- }
93
- const handlers = [
94
- [rpc, new RPCHandler(router)],
95
- [api, new OpenAPIHandler(router)],
96
- ];
97
- return {
98
- name: 'orpc',
99
- router: router,
100
- /**
101
- * `null` when neither prefix matched, so a host keeps its own routes. The path is matched
102
- * before the actor thunk is called, so a host's own route runs no strategy.
103
- */
104
- handle: async (request, actor) => {
105
- const { pathname } = new URL(request.url);
106
- const under = (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
107
- if (!handlers.some(([prefix]) => under(prefix)))
108
- return null;
109
- // Built before any procedure runs, so a throw here is outside the middleware;
110
- // it still reaches `onError`.
111
- let held;
33
+ };
34
+ // Per request, because a procedure's handler cannot read the request itself.
35
+ const warnings = new AsyncLocalStorage();
36
+ // The one place a refusal becomes a wire error, so both handlers share it.
37
+ const base = os.$context().use(async ({ next }) => {
112
38
  try {
113
- const who = await actor();
114
- held = context ? await context(request, who) : { actor: who };
39
+ return await next();
115
40
  }
116
41
  catch (thrown) {
117
- if (!(thrown instanceof AsasError))
42
+ // oRPC validates the bound input inside this middleware, so its own refusal is
43
+ // re-emitted in the runner's shape: one shape answers every 400 a procedure raises.
44
+ thrown = retryConflict(thrown);
45
+ const held = thrown instanceof AsasError ? thrown : boundaryRefusal(thrown);
46
+ if (!held) {
118
47
  report(thrown);
119
- throw thrown;
120
- }
121
- for (const [prefix, handler] of handlers) {
122
- if (!under(prefix))
123
- continue;
124
- const warning = { options: draftOptions(request) };
125
- const answered = await warnings.run(warning, () => handler.handle(request, { context: held, prefix }));
126
- if (answered.matched) {
127
- if (warning.said)
128
- answered.response.headers.set(afterCommitHeader, warning.said);
129
- return answered.response;
48
+ throw thrown;
130
49
  }
50
+ throw new ORPCError(codes[held.status], {
51
+ message: held.message,
52
+ data: { key: held.key, params: held.params },
53
+ // Never serialize the cause: a conflict's driver error names the column and value
54
+ // on its `detail`. It stays in process only because oRPC's `toJSON` never emits `cause`.
55
+ cause: held,
56
+ });
131
57
  }
132
- return null;
133
- },
58
+ });
59
+ // No prototype, so a kayan or operation named `constructor` or `__proto__` is an own key.
60
+ const router = Object.create(null);
61
+ for (const operation of operations) {
62
+ const kayan = (router[operation.kayan] ??= Object.create(null));
63
+ const input = operation.input;
64
+ const zod = input['~standard'].vendor === 'zod';
65
+ // The published shape stays for the OpenAPI contract; only validation is skipped,
66
+ // because the runner parses every input before any store seal, and a draft against the draft shape.
67
+ const wireInput = Object.assign(Object.create(input), {
68
+ '~standard': {
69
+ ...input['~standard'],
70
+ validate: (value, options) => {
71
+ const decoded = zod ? withoutPrototype(decodeJson(value, input)) : value;
72
+ const object = decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded);
73
+ return warnings.getStore()?.options.draft && object ? { value: decoded } : input['~standard'].validate(decoded, options);
74
+ },
75
+ },
76
+ });
77
+ kayan[operation.name] = base
78
+ .input(wireInput)
79
+ .handler(async ({ context: held, input }) => {
80
+ try {
81
+ const { actor, ...rest } = held;
82
+ const result = await operation.call(input, { actor, context: context ? rest : undefined, ...warnings.getStore()?.options });
83
+ const warning = warnings.getStore();
84
+ if (warning && operation.effect !== 'read')
85
+ warning.said = 'ok';
86
+ return result;
87
+ }
88
+ catch (thrown) {
89
+ if (!(thrown instanceof AfterCommitError))
90
+ throw thrown;
91
+ report(thrown.cause);
92
+ const warning = warnings.getStore();
93
+ if (warning)
94
+ warning.said = 'failed';
95
+ return thrown.result;
96
+ }
97
+ });
98
+ }
99
+ const handlers = [
100
+ [rpc, new RPCHandler(router)],
101
+ [api, new OpenAPIHandler(router)],
102
+ ];
103
+ return {
104
+ name: 'orpc',
105
+ router: router,
106
+ /**
107
+ * `null` when neither prefix matched, so a host keeps its own routes. The path is matched
108
+ * before the actor thunk is called, so a host's own route runs no strategy.
109
+ */
110
+ handle: async (request, actor) => {
111
+ const { pathname } = new URL(request.url);
112
+ const under = (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
113
+ if (!handlers.some(([prefix]) => under(prefix)))
114
+ return null;
115
+ // Read here, so oRPC's own reader never holds a body past the limit. Off a clone, so a plug
116
+ // after this one reads the body of a route oRPC does not answer.
117
+ if (request.body) {
118
+ const read = await boundedBody(request.clone(), maxBodyBytes);
119
+ if (read instanceof Response) {
120
+ // The clone's twin, so the source stream is cancelled rather than left to the server.
121
+ request.body.cancel().catch(() => { });
122
+ return read;
123
+ }
124
+ request = new Request(request.url, { method: request.method, headers: request.headers, body: read, signal: request.signal });
125
+ }
126
+ // Built before any procedure runs, so a throw here is outside the middleware;
127
+ // it still reaches `onError`.
128
+ let held;
129
+ try {
130
+ const who = await actor();
131
+ held = context ? await context(request, who) : { actor: who };
132
+ }
133
+ catch (thrown) {
134
+ if (!(thrown instanceof AsasError))
135
+ report(thrown);
136
+ throw thrown;
137
+ }
138
+ for (const [prefix, handler] of handlers) {
139
+ if (!under(prefix))
140
+ continue;
141
+ const warning = { options: draftOptions(request) };
142
+ const answered = await warnings.run(warning, () => handler.handle(request, { context: held, prefix }));
143
+ if (answered.matched) {
144
+ if (warning.said)
145
+ answered.response.headers.set(afterCommitHeader, warning.said);
146
+ return answered.response;
147
+ }
148
+ }
149
+ return null;
150
+ },
151
+ };
134
152
  };
135
153
  };
@@ -1,4 +1,4 @@
1
- import { type ResolvedOperation, type WriteTools } from './steps.ts';
1
+ import { type ResolvedOperation, type RuntimeWriteTools as WriteTools } from './steps.ts';
2
2
  import { type Authorize, type ManagedAdmin, type Options } from './write.ts';
3
3
  import type { ResolvedKayan } from '../../mithaq/types.ts';
4
4
  import type { Store } from '../../mithaq/language.ts';
@@ -1,11 +1,12 @@
1
1
  // One transaction for every row: a row that cannot be written rolls the whole call
2
2
  // back, because a bulk that half happened is a state no client can undo.
3
- import { AfterCommitError, AsasError, invalid } from './errors.js';
3
+ import { stepTools } from './store.js';
4
+ import { AfterCommitError, AsasError, invalid, retryConflict } from './errors.js';
4
5
  import { assertReadableWhere, checkedWhere } from './read.js';
5
6
  import { running } from './runtime.js';
6
7
  import { refuseTenantOption, writeTenant } from './tenant.js';
7
8
  import { atEffect, parse, validated } from './steps.js';
8
- import { aliveWhere, beginWrite, drain, ontoRow, projectReadableFields, publishedWhere, runOutside } from './write.js';
9
+ import { aliveWhere, beginWrite, drain, ontoRow, projectReadableFields, publishedWhere, rowLock, runOutside } from './write.js';
9
10
  import { combineWhere } from '../../mithaq/where.js';
10
11
  // The bulk's own key is asked once, then each row goes through the single-row
11
12
  // write, which asks its own key and field access: no key grants more through a bulk.
@@ -21,7 +22,7 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
21
22
  }
22
23
  const { actor, context, entry, open } = beginWrite({ kayan, operation: tools.operation, store, cap, options });
23
24
  refuseTenantOption({ kayan, operation: tools.operation, options });
24
- writeTenant({ kayan, active: tenantOf(actor), effect: tools.effect, data: given?.data });
25
+ const owner = writeTenant({ kayan, active: tenantOf(actor), effect: tools.effect, data: given?.data });
25
26
  // Asked once, before `beforeTransaction`, so a refused actor runs no outside step.
26
27
  const accessWhere = await authorize({
27
28
  admin,
@@ -42,7 +43,7 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
42
43
  tools,
43
44
  slot: 'beforeTransaction',
44
45
  list: atEffect(declaration.beforeTransaction),
45
- start: { kayan, data: { ...given }, actor, user: actor, tx: undefined, context },
46
+ start: { kayan, data: { ...given }, actor, user: actor, tx: undefined, context, tenant: owner },
46
47
  around: { context, user: actor },
47
48
  });
48
49
  values = before.data;
@@ -69,11 +70,12 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
69
70
  }
70
71
  // The rows a plain read shows, so a bulk never reaches a draft that was never published.
71
72
  const where = combineWhere(combineWhere(callerWhere, accessWhere), publishedWhere(kayan));
72
- // `lock: 'update'` holds every matched row until commit; the limit is the ceiling,
73
- // and `totalDocs` counts the whole matched set without reading it.
73
+ const values = (data.data ?? {});
74
+ // Holds every matched row until commit, as each row's own write then holds it; the limit is the
75
+ // ceiling, and `totalDocs` counts the whole matched set without reading it.
74
76
  const found = await store.find({
75
77
  kayan: kayan.name,
76
- args: { limit: ceiling, page: 1, lock: 'update', sort: { field: 'id', direction: 'asc' }, where },
78
+ args: { limit: ceiling, page: 1, lock: rowLock({ kayan, kind: tools.kind, data: values }), sort: { field: 'id', direction: 'asc' }, where },
77
79
  tx,
78
80
  });
79
81
  // Refused rather than written in part: a caller could not tell which rows a partial
@@ -85,7 +87,6 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
85
87
  params: { kayan: kayan.name, operation: tools.operation, matched: found.totalDocs, ceiling },
86
88
  });
87
89
  }
88
- const values = (data.data ?? {});
89
90
  const docs = [];
90
91
  // In order, not in parallel: each write joins this transaction, which is one connection.
91
92
  for (const row of found.docs) {
@@ -117,15 +118,16 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
117
118
  entry.tx = tx;
118
119
  return running.run({ context, transactions: new Map(open).set(store, entry), user: actor }, async () => {
119
120
  const runTools = Object.freeze({ ...tools, many: (data) => many(data, tx) });
120
- let ctx = { kayan, data: { ...values }, actor, user: actor, tx, context };
121
+ const publicTools = stepTools(runTools);
122
+ let ctx = { kayan, data: { ...values }, actor, user: actor, tx, context, tenant: owner };
121
123
  // No defaults here: each row's single-row write fills its own.
122
- ctx = await parse({ schema: runTools.schema, ctx, tools: runTools });
124
+ ctx = await parse({ schema: runTools.schema, ctx, tools: publicTools });
123
125
  for (const step of atEffect([
124
126
  ...declaration.beforeSteps,
125
127
  ...list,
126
128
  ...declaration.afterSteps,
127
129
  ]))
128
- ctx = await step(ctx, runTools);
130
+ ctx = { ...(await step(ctx, publicTools)), tenant: owner };
129
131
  committed = ctx;
130
132
  // Each document already passed its single-row output and read mask.
131
133
  // The bulk contract validates the envelope after the steps have run.
@@ -174,6 +176,9 @@ export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit:
174
176
  try {
175
177
  result = await store.transaction(run);
176
178
  }
179
+ catch (thrown) {
180
+ throw retryConflict(thrown);
181
+ }
177
182
  finally {
178
183
  entry.settled = true;
179
184
  }
@@ -4,6 +4,7 @@ export declare const codes: {
4
4
  readonly 403: 'FORBIDDEN';
5
5
  readonly 404: 'NOT_FOUND';
6
6
  readonly 409: 'CONFLICT';
7
+ readonly 413: 'PAYLOAD_TOO_LARGE';
7
8
  readonly 429: 'TOO_MANY_REQUESTS';
8
9
  readonly 503: 'SERVICE_UNAVAILABLE';
9
10
  };
@@ -48,10 +49,12 @@ export declare const invalid: ({ message, key, params, cause }: Refusal) => Asas
48
49
  */
49
50
  export declare const missing: ({ message, key, params, cause }: Refusal) => AsasError;
50
51
  /**
51
- * The write collides with a row already there, as a 409 so an offline client
52
- * does not retry it. It never says which column or whose row it hit.
52
+ * The write cannot complete, as a 409. Its key says whether trying again can help.
53
+ * It never says which column or whose row it hit.
53
54
  */
54
55
  export declare const conflict: ({ message, key, params, cause }: Refusal) => AsasError;
56
+ /** A transaction the database rolled back may be retried, without exposing its query or rows. */
57
+ export declare const retryConflict: (thrown: unknown) => unknown;
55
58
  export declare const localizedQuery: ({ kayan, field, what, locales, }: {
56
59
  kayan: string;
57
60
  field: string;
@@ -5,6 +5,7 @@ export const codes = {
5
5
  403: 'FORBIDDEN',
6
6
  404: 'NOT_FOUND',
7
7
  409: 'CONFLICT',
8
+ 413: 'PAYLOAD_TOO_LARGE',
8
9
  429: 'TOO_MANY_REQUESTS',
9
10
  503: 'SERVICE_UNAVAILABLE',
10
11
  };
@@ -48,10 +49,25 @@ export const invalid = ({ message, key, params, cause }) => new AsasError(messag
48
49
  */
49
50
  export const missing = ({ message, key, params, cause }) => new AsasError(message, 404, key, params, cause);
50
51
  /**
51
- * The write collides with a row already there, as a 409 so an offline client
52
- * does not retry it. It never says which column or whose row it hit.
52
+ * The write cannot complete, as a 409. Its key says whether trying again can help.
53
+ * It never says which column or whose row it hit.
53
54
  */
54
55
  export const conflict = ({ message, key, params, cause }) => new AsasError(message, 409, key, params, cause);
56
+ /** A transaction the database rolled back may be retried, without exposing its query or rows. */
57
+ export const retryConflict = (thrown) => {
58
+ if (thrown instanceof AsasError || thrown instanceof AfterCommitError)
59
+ return thrown;
60
+ const seen = new Set();
61
+ for (let cause = thrown; cause && typeof cause === 'object' && !seen.has(cause); cause = cause.cause) {
62
+ if (cause instanceof AfterCommitError)
63
+ return thrown;
64
+ seen.add(cause);
65
+ if (['40P01', '40001'].includes(String(cause.code))) {
66
+ return conflict({ message: 'The transaction conflicted with another write. Try again.', key: 'error.conflict.retry', cause: thrown });
67
+ }
68
+ }
69
+ return thrown;
70
+ };
55
71
  // Shared by the read layer, which answers the caller with a 400, and the drizzle
56
72
  // store, which throws at the access callback that built the query.
57
73
  export const localizedQuery = ({ kayan, field, what, locales = [], }) => what === 'sort'
@@ -2,7 +2,7 @@ import { type Authorize, type ManagedAdmin, type ReadOptions } from './write.ts'
2
2
  import { type Where } from '../../mithaq/where.ts';
3
3
  import { type AuditHandler, type ReadTools, type ResolvedOperation } from './steps.ts';
4
4
  import type { StandardSchemaV1 } from '../../mithaq/standard-schema.ts';
5
- import { type ResolvedKayan } from '../../mithaq/types.ts';
5
+ import { type ResolvedField, type ResolvedKayan } from '../../mithaq/types.ts';
6
6
  import type { Store } from '../../mithaq/language.ts';
7
7
  import { type TenantScope } from './tenant.ts';
8
8
  /** What a `find` list takes where it declares no input of its own. */
@@ -45,7 +45,7 @@ export declare const assertReadableWhere: <Actor>({ kayan, where, actor, authori
45
45
  }) => Promise<Where | undefined>;
46
46
  /** The caller's own `where`, refused as a 400 where its grammar is malformed or a value is not of its column's kind. */
47
47
  export declare const checkedWhere: (kayan: ResolvedKayan, where: unknown) => Promise<Where>;
48
- export declare const readers: <Actor>({ kayan, store, cap, defaultLimit, joinLimit, admin, authorize, kayans, of, audit, catalog, openTenant, }: {
48
+ export declare const readers: <Actor>({ kayan, store, cap, defaultLimit, joinLimit, admin, authorize, kayans, of, audit, catalog, openTenant, tenantOf, pickers, }: {
49
49
  kayan: ResolvedKayan;
50
50
  store: Store;
51
51
  cap: number;
@@ -65,4 +65,8 @@ export declare const readers: <Actor>({ kayan, store, cap, defaultLimit, joinLim
65
65
  reach: unknown;
66
66
  kayan: ResolvedKayan;
67
67
  }) => Promise<TenantScope | undefined>;
68
+ /** The organization the actor's session names, which a step reads as `ctx.tenant`. */
69
+ tenantOf: (actor: unknown) => string | undefined;
70
+ /** Every relationship field pointing at this kayan, by `<kayan>.<field>`, which a picker's read names as `for`. */
71
+ pickers?: Readonly<Record<string, ResolvedField>>;
68
72
  }) => ReaderMap<Actor>;
@@ -1,4 +1,4 @@
1
- import { aliveWhere, assertSingleConstrained, assertWhereGrammar, publicResult, publishedWhere } from './write.js';
1
+ import { aliveWhere, assertSingleConstrained, assertWhere, assertWhereGrammar, publicResult, publishedWhere } from './write.js';
2
2
  import { combineWhere } from '../../mithaq/where.js';
3
3
  import { atEffect, issueRefusal, parse } from './steps.js';
4
4
  import { sortableFields, unsortable } from '../../mithaq/types.js';
@@ -55,7 +55,8 @@ const protectedField = async (kayan, field, actor, authorize, admin, what = 'fil
55
55
  // A locale is one column of the root field: no hop, and read access is per field, so
56
56
  // the check over the root is the whole security surface.
57
57
  const locale = relatedField && root?.locales?.includes(relatedField) ? relatedField : undefined;
58
- const hop = locale ? undefined : relatedField;
58
+ // A json field's path stays inside its own value, so the field's read access is the whole of it.
59
+ const hop = locale || root?.kind === 'json' ? undefined : relatedField;
59
60
  const found = hop ? root?.target?.fields[hop] : root;
60
61
  if (!root || !found)
61
62
  throw invalid({ message: `Kayan "${kayan.name}" query names unknown field "${field}".`, key: 'error.invalid.unknown_field', params: { kayan: kayan.name, field } });
@@ -160,6 +161,9 @@ const isoMoment = /^\d{4}-\d{2}-\d{2}(?:[T ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d
160
161
  const coerced = (kind, value) => {
161
162
  if (typeof value !== 'string')
162
163
  return value;
164
+ // Lower-cased, as the parse lower-cases a uuid, so a store comparing text finds the row too.
165
+ if (kind === 'uuid')
166
+ return value.toLowerCase();
163
167
  if (kind === 'checkbox')
164
168
  return value.toLowerCase() === 'true' ? true : value.toLowerCase() === 'false' ? false : value;
165
169
  if ((kind === 'number' || kind === 'integer') && value.trim() !== '' && Number.isFinite(Number(value)))
@@ -178,6 +182,9 @@ const whereIssues = async (kayan, where, at) => {
178
182
  }
179
183
  const [name, second] = key.split('.');
180
184
  const field = kayan.fields[name];
185
+ // A value inside a json field is any scalar, which no column of a kind holds.
186
+ if (field.kind === 'json')
187
+ continue;
181
188
  // A hop compares a column of the kayan the field points at, and a set of
182
189
  // many named alone compares that kayan's id; a locale is a column of this one.
183
190
  const hop = !!field.target && (second ? !field.locales?.includes(second) : !!field.hasMany);
@@ -277,7 +284,7 @@ const sortOf = async (kayan, sort, actor, authorize, admin) => {
277
284
  await protectedField(kayan, field, actor, authorize, admin, 'sort');
278
285
  return { field, direction };
279
286
  };
280
- export const readers = ({ kayan, store, cap, defaultLimit, joinLimit, admin, authorize, kayans, of, audit, catalog = [], openTenant, }) => {
287
+ export const readers = ({ kayan, store, cap, defaultLimit, joinLimit, admin, authorize, kayans, of, audit, catalog = [], openTenant, tenantOf, pickers = {}, }) => {
281
288
  // Every managed read reaches the store here, so the soft-delete filter is put on once.
282
289
  const alive = aliveWhere(kayan);
283
290
  // A draft read reads published rows and the drafts `findVersions` admits, so no actor
@@ -377,10 +384,23 @@ export const readers = ({ kayan, store, cap, defaultLimit, joinLimit, admin, aut
377
384
  }
378
385
  return judged;
379
386
  };
387
+ // A picker's read for one relationship field holds to what that field's `filterOptions` offers the
388
+ // actor, ANDed beside the read's own access, so it narrows and never widens.
389
+ const offeredFor = async (named, actor) => {
390
+ if (named === undefined)
391
+ return undefined;
392
+ const field = typeof named === 'string' && Object.hasOwn(pickers, named) ? pickers[named] : undefined;
393
+ if (!field)
394
+ throw invalid({ message: `Managed read option "for" names ${JSON.stringify(named)}, which is no relationship field pointing at kayan "${kayan.name}".`, key: 'error.invalid.read_option', params: { option: 'for' } });
395
+ const offered = await field.filterOptions?.({ user: actor });
396
+ if (offered === undefined || offered === true)
397
+ return undefined;
398
+ return offered === false ? { id: { in: [] } } : assertWhere(kayan, offered);
399
+ };
380
400
  const fetchList = async (input, actor, granted, drafts) => {
381
401
  const accessWhere = granted ? granted.where : await readAccess(kayan, actor, authorize, admin);
382
402
  const callerWhere = await judgedWhere(input.where, actor, accessWhere);
383
- const where = combineWhere(callerWhere, accessWhere);
403
+ const where = combineWhere(combineWhere(callerWhere, accessWhere), await offeredFor(input.for, actor));
384
404
  const limit = integerOption('limit', input.limit, defaultLimit, 0);
385
405
  const page = integerOption('page', input.page, 1, 1);
386
406
  const [first, ...rest] = judges(actor, accessWhere);
@@ -515,14 +535,16 @@ export const readers = ({ kayan, store, cap, defaultLimit, joinLimit, admin, aut
515
535
  },
516
536
  });
517
537
  const tx = currentTx();
518
- let ctx = { kayan, data: { ...data }, actor, user: actor, tx, context };
538
+ // A reach past one organization names none; a global is its active organization's row under any reach.
539
+ const owner = kayan.tenant && (!tenant || kayan.single) ? tenantOf(actor) : undefined;
540
+ let ctx = { kayan, data: { ...data }, actor, user: actor, tx, context, tenant: owner };
519
541
  const run = async () => {
520
542
  // A read fills no default: it writes no column.
521
543
  ctx = await parse({ schema: input, ctx, tools });
522
544
  // `strictFunctionTypes` checks the actor contravariantly, hence the one cast. A read runs
523
545
  // no service-target list: it opens no transaction, and `audit` is taken only by naming it.
524
546
  for (const step of atEffect([...list, ...declaration.afterSteps]))
525
- ctx = await step(ctx, tools);
547
+ ctx = { ...(await step(ctx, tools)), tenant: owner };
526
548
  };
527
549
  await running.run(around, run);
528
550
  // A total carries no column, so nothing to mask.
@@ -2,7 +2,7 @@ import type { StandardSchemaIssue, StandardSchemaV1 } from '../../mithaq/standar
2
2
  import type { BoundOf } from './write.ts';
3
3
  import type { Where } from '../../mithaq/where.ts';
4
4
  import type { ResolvedKayan, RuntimeField, ServiceOf } from '../../mithaq/types.ts';
5
- import type { Store } from '../../mithaq/language.ts';
5
+ import type { RawStepStore, StepStore, Store } from '../../mithaq/language.ts';
6
6
  /**
7
7
  * What the caller passed, plus the three keys the runner keeps. `Extra` is the service target's
8
8
  * `context`, so a project reads its own keys typed. Held in an `AsyncLocalStorage`, so nested writes are counted.
@@ -31,6 +31,13 @@ export type Ctx<Data = Record<string, unknown>, Actor = unknown, Tx = unknown, E
31
31
  /** The store's transaction. Every write in the list goes through it. */
32
32
  tx: Tx;
33
33
  context: Context<Extra>;
34
+ /**
35
+ * The active organization the operation is scoped to, for a step that keeps something per
36
+ * organization. `undefined` on a shared kayan, under a read's `tenant` option, which reaches
37
+ * past one organization, and where the language has no tenant target. Only a copy: the runner
38
+ * puts it back after every step and scopes from the session, so rewriting it moves nothing.
39
+ */
40
+ readonly tenant?: string;
34
41
  };
35
42
  /** The keys the project put on the service target's `context`, or the open record where it put none. */
36
43
  export type ContextOf<Service> = Service extends {
@@ -85,8 +92,8 @@ export type WriteTools<Kayans = unknown, Actor = unknown, Tx = unknown> = Common
85
92
  kind: 'deleteMany' | 'insert' | 'remove' | 'update' | 'updateMany';
86
93
  /** One kayan's bound operations, found and typed by the declaration handed in, so a step imports the kayan it calls. */
87
94
  of: BoundOf<'write'>;
88
- /** The store's three writes, each bound, and the store itself is not here. */
89
- store: Pick<Store, 'insert' | 'remove' | 'update'>;
95
+ /** Bound store calls, sealed to the active organization; no adapter or client is exposed. */
96
+ store: StepStore;
90
97
  /** Access constraint already granted for this operation, if access returned one. */
91
98
  where?: Where;
92
99
  /** The keys the caller's own object carried, read before any step ran, so validation can tell the runner's values from the caller's. */
@@ -113,6 +120,10 @@ export type BulkResult<Doc = Record<string, unknown>> = {
113
120
  message: string;
114
121
  }[];
115
122
  };
123
+ /** The runner keeps string names on its side of the step boundary; adapters never receive declarations. */
124
+ export type RuntimeWriteTools<Kayans = unknown, Actor = unknown, Tx = unknown> = Omit<WriteTools<Kayans, Actor, Tx>, 'store'> & {
125
+ store: RawStepStore;
126
+ };
116
127
  export type Tools = ReadTools | WriteTools;
117
128
  /** The positions `resolve` checks: the store and read steps, each named after the `Store` method it calls. */
118
129
  export type Seal = 'audit' | 'count' | 'defaults' | 'deleteMany' | 'find' | 'findOne' | 'insert' | 'remove' | 'update' | 'updateMany' | 'validate' | 'findVersions' | 'findVersionByID' | 'restoreVersion';
@@ -215,6 +226,8 @@ Extra extends Record<string, unknown> = Record<string, unknown>> = {
215
226
  afterCommit?: SlotSteps<Given, Extra>;
216
227
  /** A public output contract, parsed after field masking. */
217
228
  output?: RowOutput | ((schemas: Schemas) => RowOutput);
229
+ /** On a bulk operation, the kayan's single-row operation it runs on each matched row, handed `data` and the row's id. Without it, `update` or `delete`. */
230
+ each?: string;
218
231
  /** One entry per target with something to say about this operation, by the target's name: `targets.<screen>` is what that screen's button asks for. */
219
232
  targets?: Readonly<Record<string, OperationScreen<Input> | undefined>>;
220
233
  };
@@ -235,6 +248,7 @@ export type AnyOperation = {
235
248
  afterSteps?: OperationSteps<never, never> | ((defaults: never) => OperationSteps<never, never>);
236
249
  afterCommit?: OperationSteps<never, never> | ((defaults: never) => OperationSteps<never, never>);
237
250
  output?: RowOutput | ((schemas: never) => RowOutput);
251
+ each?: string;
238
252
  targets?: Readonly<Record<string, OperationScreen | undefined>>;
239
253
  };
240
254
  export type Operations<Given = Lists, Schemas = DerivedShapes, Locale extends string = string, Input = never> = {
@@ -255,11 +269,15 @@ export type ResolvedOperation = {
255
269
  mode?: ReadMode;
256
270
  /** Always a schema: an `output` written as a function is resolved at build. */
257
271
  output?: RowOutput;
272
+ /** On a bulk operation, the single-row operation it runs per row. */
273
+ each?: string;
258
274
  /** What each target says about this operation, as the declaration wrote it. */
259
275
  targets?: Readonly<Record<string, OperationScreen | undefined>>;
260
276
  };
261
277
  /** What position this step holds, or nothing at all for a step of the app's own. */
262
278
  export declare const sealOf: (step: OperationStep<never>) => Seal | undefined;
279
+ /** Where the other copy of mithaq that sealed this step was loaded from, if one did. */
280
+ export declare const foreignCopyOf: (step: OperationStep<never>) => string | undefined;
263
281
  /** Hold an operation's `output` function until `build` has the derived shapes. */
264
282
  export declare const holdOutput: (operation: ResolvedOperation, of: (schemas: never) => RowOutput) => void;
265
283
  /** The schema function this operation's `output` carries, or nothing for a bare schema. */
@@ -396,5 +414,5 @@ export type LanguageSlots = {
396
414
  afterSteps?: readonly ProjectStep[];
397
415
  afterCommit?: readonly ProjectStep[];
398
416
  };
399
- export declare const resolveOperations: (declared: DeclaredOperations | ((defaults: never) => DeclaredOperations) | undefined, slots?: LanguageSlots) => Record<string, AnyOperation>;
417
+ export declare const resolveOperations: (declared: DeclaredOperations | undefined, slots?: LanguageSlots) => Record<string, AnyOperation>;
400
418
  export {};