@drhomet/mithaq 0.0.0-stage → 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 (152) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/README.md +160 -2
  6. package/dist/asas/actor.d.ts +96 -0
  7. package/dist/asas/actor.js +114 -0
  8. package/dist/asas/build.d.ts +239 -0
  9. package/dist/asas/build.js +834 -0
  10. package/dist/asas/cli/mithaq.d.ts +2 -0
  11. package/dist/asas/cli/mithaq.js +177 -0
  12. package/dist/asas/cli/upgrade.d.ts +33 -0
  13. package/dist/asas/cli/upgrade.js +256 -0
  14. package/dist/asas/http/fetch.d.ts +41 -0
  15. package/dist/asas/http/fetch.js +178 -0
  16. package/dist/asas/http/orpc.d.ts +45 -0
  17. package/dist/asas/http/orpc.js +153 -0
  18. package/dist/asas/runner/bulk.d.ts +20 -0
  19. package/dist/asas/runner/bulk.js +196 -0
  20. package/dist/asas/runner/errors.d.ts +63 -0
  21. package/dist/asas/runner/errors.js +75 -0
  22. package/dist/asas/runner/read.d.ts +72 -0
  23. package/dist/asas/runner/read.js +601 -0
  24. package/dist/asas/runner/runtime.d.ts +21 -0
  25. package/dist/asas/runner/runtime.js +8 -0
  26. package/dist/asas/runner/steps.d.ts +418 -0
  27. package/dist/asas/runner/steps.js +277 -0
  28. package/dist/asas/runner/store.d.ts +4 -0
  29. package/dist/asas/runner/store.js +21 -0
  30. package/dist/asas/runner/tenant.d.ts +54 -0
  31. package/dist/asas/runner/tenant.js +275 -0
  32. package/dist/asas/runner/write.d.ts +606 -0
  33. package/dist/asas/runner/write.js +1019 -0
  34. package/dist/auth/better-auth/access.d.ts +29 -0
  35. package/dist/auth/better-auth/access.js +103 -0
  36. package/dist/auth/better-auth/generate.d.ts +31 -0
  37. package/dist/auth/better-auth/generate.js +328 -0
  38. package/dist/auth/better-auth/index.d.ts +94 -0
  39. package/dist/auth/better-auth/index.js +81 -0
  40. package/dist/auth/better-auth/tenant.d.ts +26 -0
  41. package/dist/auth/better-auth/tenant.js +34 -0
  42. package/dist/auth/local/index.d.ts +285 -0
  43. package/dist/auth/local/index.js +738 -0
  44. package/dist/base/decimal.d.ts +9 -0
  45. package/dist/base/decimal.js +16 -0
  46. package/dist/base/fields.d.ts +124 -0
  47. package/dist/base/fields.js +52 -0
  48. package/dist/base/index.d.ts +47 -0
  49. package/dist/base/index.js +5 -0
  50. package/dist/base/json-schema.d.ts +7 -0
  51. package/dist/base/json-schema.js +35 -0
  52. package/dist/base/kinds.d.ts +75 -0
  53. package/dist/base/kinds.js +123 -0
  54. package/dist/base/rules.d.ts +73 -0
  55. package/dist/base/rules.js +81 -0
  56. package/dist/drizzle/index.d.ts +308 -0
  57. package/dist/drizzle/index.js +951 -0
  58. package/dist/drizzle/kinds.d.ts +28 -0
  59. package/dist/drizzle/kinds.js +43 -0
  60. package/dist/drizzle/migrate.d.ts +159 -0
  61. package/dist/drizzle/migrate.js +1328 -0
  62. package/dist/drizzle/proxy/index.d.ts +1 -0
  63. package/dist/drizzle/proxy/index.js +1 -0
  64. package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
  65. package/dist/drizzle/proxy/node-postgres.js +1 -0
  66. package/dist/drizzle/proxy/pg-core.d.ts +1 -0
  67. package/dist/drizzle/proxy/pg-core.js +1 -0
  68. package/dist/drizzle/rules.d.ts +336 -0
  69. package/dist/drizzle/rules.js +44 -0
  70. package/dist/index.d.ts +35 -0
  71. package/dist/index.js +26 -0
  72. package/dist/mithaq/define.d.ts +335 -0
  73. package/dist/mithaq/define.js +57 -0
  74. package/dist/mithaq/generate-messages.d.ts +38 -0
  75. package/dist/mithaq/generate-messages.js +235 -0
  76. package/dist/mithaq/language.d.ts +374 -0
  77. package/dist/mithaq/language.js +76 -0
  78. package/dist/mithaq/lock.d.ts +71 -0
  79. package/dist/mithaq/lock.js +329 -0
  80. package/dist/mithaq/message-file.d.ts +13 -0
  81. package/dist/mithaq/message-file.js +29 -0
  82. package/dist/mithaq/messages.d.ts +55 -0
  83. package/dist/mithaq/messages.js +111 -0
  84. package/dist/mithaq/refusal.d.ts +6 -0
  85. package/dist/mithaq/refusal.js +6 -0
  86. package/dist/mithaq/resolve.d.ts +10 -0
  87. package/dist/mithaq/resolve.js +1014 -0
  88. package/dist/mithaq/rows.d.ts +87 -0
  89. package/dist/mithaq/rows.js +174 -0
  90. package/dist/mithaq/rukn.d.ts +72 -0
  91. package/dist/mithaq/rukn.js +15 -0
  92. package/dist/mithaq/standard-schema.d.ts +38 -0
  93. package/dist/mithaq/standard-schema.js +2 -0
  94. package/dist/mithaq/types.d.ts +386 -0
  95. package/dist/mithaq/types.js +15 -0
  96. package/dist/mithaq/user.d.ts +19 -0
  97. package/dist/mithaq/user.js +16 -0
  98. package/dist/mithaq/where.d.ts +32 -0
  99. package/dist/mithaq/where.js +87 -0
  100. package/dist/rbac/index.d.ts +105 -0
  101. package/dist/rbac/index.js +182 -0
  102. package/dist/rbac/steps.d.ts +22 -0
  103. package/dist/rbac/steps.js +161 -0
  104. package/dist/rich-text/index.d.ts +28 -0
  105. package/dist/rich-text/index.js +59 -0
  106. package/dist/rich-text/rich-text.d.ts +106 -0
  107. package/dist/rich-text/rich-text.js +249 -0
  108. package/dist/rich-text/value.d.ts +55 -0
  109. package/dist/rich-text/value.js +126 -0
  110. package/dist/screen/client.d.ts +156 -0
  111. package/dist/screen/client.js +71 -0
  112. package/dist/screen/generate.d.ts +14 -0
  113. package/dist/screen/generate.js +268 -0
  114. package/dist/screen/index.d.ts +496 -0
  115. package/dist/screen/index.js +663 -0
  116. package/dist/screen/react.d.ts +97 -0
  117. package/dist/screen/react.js +58 -0
  118. package/dist/screen/studio.d.ts +24 -0
  119. package/dist/screen/studio.js +68 -0
  120. package/dist/service/index.d.ts +63 -0
  121. package/dist/service/index.js +15 -0
  122. package/dist/storage/allowed-type.d.ts +5 -0
  123. package/dist/storage/allowed-type.js +7 -0
  124. package/dist/storage/index.d.ts +42 -0
  125. package/dist/storage/index.js +43 -0
  126. package/dist/storage/storage.d.ts +386 -0
  127. package/dist/storage/storage.js +412 -0
  128. package/dist/targets.d.ts +14 -0
  129. package/dist/targets.js +16 -0
  130. package/dist/versions/index.d.ts +37 -0
  131. package/dist/versions/index.js +8 -0
  132. package/dist/versions/versions.d.ts +104 -0
  133. package/dist/versions/versions.js +160 -0
  134. package/dist/vite.d.ts +7 -0
  135. package/dist/vite.js +81 -0
  136. package/dist/zod/index.d.ts +193 -0
  137. package/dist/zod/index.js +122 -0
  138. package/dist/zod/kinds.d.ts +36 -0
  139. package/dist/zod/kinds.js +58 -0
  140. package/dist/zod/rules.d.ts +117 -0
  141. package/dist/zod/rules.js +67 -0
  142. package/docs/packages/mithaq/README.md +962 -0
  143. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  144. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  145. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  146. package/docs/packages/mithaq-admin/README.md +312 -0
  147. package/docs/templates/asas/README.md +1056 -0
  148. package/package.json +92 -4
  149. package/skills/add-field/SKILL.md +27 -0
  150. package/skills/add-kayan/SKILL.md +26 -0
  151. package/skills/eject-admin-piece/SKILL.md +28 -0
  152. package/skills/upgrade-mithaq/SKILL.md +35 -0
@@ -0,0 +1,601 @@
1
+ import { aliveWhere, assertSingleConstrained, assertWhere, assertWhereGrammar, publicResult, publishedWhere } from './write.js';
2
+ import { combineWhere } from '../../mithaq/where.js';
3
+ import { atEffect, issueRefusal, parse } from './steps.js';
4
+ import { sortableFields, unsortable } from '../../mithaq/types.js';
5
+ import { defaultKinds } from '../../base/kinds.js';
6
+ import { AsasError, denied, invalid, localizedQuery, missing } from './errors.js';
7
+ import { running } from './runtime.js';
8
+ import { membersAdmitted } from './tenant.js';
9
+ import { revived } from '../../versions/versions.js';
10
+ const integerOption = (name, value, fallback, minimum, maximum) => {
11
+ const actual = value ?? fallback;
12
+ if (typeof actual !== 'number' || !Number.isInteger(actual) || actual < minimum) {
13
+ throw invalid({ message: `Managed read option "${name}" must be an integer >= ${minimum}.`, key: 'error.invalid.read_option_integer', params: { option: name, minimum } });
14
+ }
15
+ // Refused rather than clamped: a clamp answers a page nobody asked for. The bound is
16
+ // `cap - 1`: the read naming the depth is itself the first level the runner counts.
17
+ if (maximum !== undefined && actual > maximum) {
18
+ throw invalid({
19
+ message: `Managed read option "${name}" must be an integer <= ${maximum}.`,
20
+ key: 'error.invalid.read_option_integer',
21
+ params: { option: name, maximum },
22
+ });
23
+ }
24
+ return actual;
25
+ };
26
+ const readAccess = async (kayan, actor, authorize, admin, id, operation) =>
27
+ // A global is found by being the only row, so an id in its input names nothing.
28
+ authorize({ admin, actor, data: id === undefined || kayan.single ? {} : { id }, effect: 'read', kayan, operation });
29
+ // A dotted key is refused: across the hop it would be three segments, and passed through
30
+ // unprefixed it would constrain the near table instead of the far one.
31
+ const prefixWhere = (kayan, prefix, where) => {
32
+ const conjunction = where.and;
33
+ if (conjunction)
34
+ return { and: conjunction.map((one) => prefixWhere(kayan, prefix, one)) };
35
+ const disjunction = where.or;
36
+ if (disjunction)
37
+ return { or: disjunction.map((one) => prefixWhere(kayan, prefix, one)) };
38
+ return Object.fromEntries(Object.entries(where).map(([field, rule]) => {
39
+ if (field.includes('.')) {
40
+ throw new Error(`Kayan "${kayan}" access constraint path "${field}" is reached across a relationship from "${prefix}", so carrying it would be three segments, and a query path is never more than two.`);
41
+ }
42
+ return [`${prefix}.${field}`, rule];
43
+ }));
44
+ };
45
+ // Raised after the field's own read access, so a caller who may not read the field
46
+ // still gets its 403 and learns nothing from this.
47
+ const localizedRefusal = (kayan, field, what, locales) => invalid({
48
+ message: localizedQuery({ kayan, field, what, locales }),
49
+ key: `error.invalid.localized_${what}`,
50
+ params: what === 'filter' ? { kayan, field, locales } : { kayan, field },
51
+ });
52
+ const protectedField = async (kayan, field, actor, authorize, admin, what = 'filter') => {
53
+ const [ownField, relatedField] = field.split('.');
54
+ const root = kayan.fields[ownField];
55
+ // A locale is one column of the root field: no hop, and read access is per field, so
56
+ // the check over the root is the whole security surface.
57
+ const locale = relatedField && root?.locales?.includes(relatedField) ? relatedField : undefined;
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;
60
+ const found = hop ? root?.target?.fields[hop] : root;
61
+ if (!root || !found)
62
+ throw invalid({ message: `Kayan "${kayan.name}" query names unknown field "${field}".`, key: 'error.invalid.unknown_field', params: { kayan: kayan.name, field } });
63
+ // A `join` is no column of any table; across a hop it would reach the store as
64
+ // `undefined`, a 500 where every other impossible path is a 400.
65
+ if (found.kind === 'join') {
66
+ const owner = hop ? root.target : kayan;
67
+ throw invalid({
68
+ message: `Kayan "${owner.name}" cannot query field "${found.name}", whose rows belong to kayan "${found.target?.name}". Read that kayan filtered by "${found.on}" instead.`,
69
+ key: 'error.invalid.join_query',
70
+ params: { kayan: owner.name, field: found.name },
71
+ });
72
+ }
73
+ const readable = async (owner, checked, user) => {
74
+ if (checked.access?.read && !(await checked.access.read({ user }))) {
75
+ throw denied({ message: `Kayan "${owner.name}" field "${checked.name}" read access denied for managed query.`, key: 'error.forbidden.field_read', params: { kayan: owner.name, field: checked.name } });
76
+ }
77
+ };
78
+ await readable(kayan, root, actor);
79
+ if (hop)
80
+ await readable(root.target, found, actor);
81
+ if (!locale && found.rules?.localized) {
82
+ throw localizedRefusal(hop ? root.target.name : kayan.name, found.name, what, found.locales);
83
+ }
84
+ // A `hasMany` set, a join and a field a screen marked unsortable have no single value
85
+ // to order by; the store would otherwise answer in whatever order the planner returned.
86
+ if (what === 'sort' && !hop && unsortable(root)) {
87
+ throw invalid({
88
+ message: !root.hasMany && root.kind !== 'join'
89
+ ? `Kayan "${kayan.name}" cannot sort by field "${root.name}", which a screen takes out of sorting.`
90
+ : `Kayan "${kayan.name}" cannot sort by field "${root.name}", which holds a set of rows rather than one value.`,
91
+ key: 'error.invalid.sort_field',
92
+ params: { kayan: kayan.name, field: root.name },
93
+ });
94
+ }
95
+ if (!hop || !root.target)
96
+ return undefined;
97
+ // The hop carries the other kayan's read access and the rows its plain read shows, so it
98
+ // answers nothing about a row no plain read of that kayan would return.
99
+ const farAccess = await readAccess(root.target, actor, authorize, admin);
100
+ // Under `tenant: 'mine'` each far row is read by the membership it came through, so a column
101
+ // hidden from one of them filters and orders none of their rows.
102
+ for (const member of membersAdmitted(running.getStore()?.tenant, farAccess) ?? [])
103
+ await readable(root.target, found, member);
104
+ const targetWhere = combineWhere(farAccess, publishedWhere(root.target));
105
+ return targetWhere ? prefixWhere(root.target.name, ownField, targetWhere) : undefined;
106
+ };
107
+ // A field this actor may not read refuses the query, and a hop carries the other kayan's
108
+ // read access. Shared with the bulk runner, whose `where` is held to the same.
109
+ export const assertReadableWhere = async ({ kayan, where, actor, authorize, admin, }) => {
110
+ if (!where)
111
+ return undefined;
112
+ const narrowed = async (checked) => {
113
+ const normalized = { ...checked };
114
+ const conjunction = checked.and;
115
+ if (conjunction)
116
+ normalized.and = await Promise.all(conjunction.map(narrowed));
117
+ const disjunction = checked.or;
118
+ if (disjunction)
119
+ normalized.or = await Promise.all(disjunction.map(narrowed));
120
+ const extras = [];
121
+ for (const field of Object.keys(normalized)) {
122
+ if (field !== 'and' && field !== 'or') {
123
+ const extra = await protectedField(kayan, field, actor, authorize, admin);
124
+ if (extra)
125
+ extras.push(extra);
126
+ }
127
+ }
128
+ return extras.length ? combineWhere(normalized, extras.length === 1 ? extras[0] : { and: extras }) : normalized;
129
+ };
130
+ return narrowed(await checkedWhere(kayan, where));
131
+ };
132
+ /** The caller's own `where`, refused as a 400 where its grammar is malformed or a value is not of its column's kind. */
133
+ export const checkedWhere = async (kayan, where) => {
134
+ // A 400 here, where the `where` is the caller's; from an access callback the same grammar
135
+ // is the project's own bug, a 500, and never tells a caller a rule it did not write is wrong.
136
+ let checked;
137
+ try {
138
+ checked = assertWhereGrammar(kayan, where);
139
+ }
140
+ catch (thrown) {
141
+ if (!(thrown instanceof Error))
142
+ throw thrown;
143
+ throw invalid({ message: thrown.message, key: 'error.invalid.where', params: { kayan: kayan.name, where }, cause: thrown });
144
+ }
145
+ return valuesChecked(kayan, checked);
146
+ };
147
+ /**
148
+ * A `where` past `assertWhereGrammar`, with each value it compares read as its
149
+ * column's kind and refused as a 400 where that kind refuses it.
150
+ */
151
+ const valuesChecked = async (kayan, checked) => {
152
+ const issues = await whereIssues(kayan, checked, ['where']);
153
+ if (issues.length)
154
+ throw issueRefusal(issues, 'mithaq');
155
+ return checked;
156
+ };
157
+ // The day is checked apart by the `date` kind's own test, since a pattern takes `2026-02-30`.
158
+ const isoMoment = /^\d{4}-\d{2}-\d{2}(?:[T ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?$/;
159
+ // Text sent for a number or a boolean is read as one. A decimal stays text, so a JS number
160
+ // sent for one is refused rather than read through a double.
161
+ const coerced = (kind, value) => {
162
+ if (typeof value !== 'string')
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();
167
+ if (kind === 'checkbox')
168
+ return value.toLowerCase() === 'true' ? true : value.toLowerCase() === 'false' ? false : value;
169
+ if ((kind === 'number' || kind === 'integer') && value.trim() !== '' && Number.isFinite(Number(value)))
170
+ return Number(value);
171
+ return value;
172
+ };
173
+ // Each value is asked of the kayan's own `row` shape, so a value its column refuses is a
174
+ // 400 rather than a Postgres 500. `null`, `exists`, `contains` and `like` compare no value of the kind.
175
+ const whereIssues = async (kayan, where, at) => {
176
+ const issues = [];
177
+ for (const [key, rules] of Object.entries(where)) {
178
+ if (key === 'and' || key === 'or') {
179
+ for (const [index, one] of rules.entries())
180
+ issues.push(...await whereIssues(kayan, one, [...at, key, index]));
181
+ continue;
182
+ }
183
+ const [name, second] = key.split('.');
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;
188
+ // A hop compares a column of the kayan the field points at, and a set of
189
+ // many named alone compares that kayan's id; a locale is a column of this one.
190
+ const hop = !!field.target && (second ? !field.locales?.includes(second) : !!field.hasMany);
191
+ const owner = hop ? field.target : kayan;
192
+ const segments = hop ? [second ?? 'id'] : second ? [name, second] : [name];
193
+ const column = owner.fields[segments[0]];
194
+ if (!owner.rowShape || !column || (column.locales && segments.length === 1))
195
+ continue;
196
+ // What the column holds: a relationship holds the id of the row it points at.
197
+ const kind = column.kind === 'relationship' ? column.target?.fields.id?.kind ?? column.kind : column.kind;
198
+ const operators = rules;
199
+ for (const [operator, operand] of Object.entries(operators)) {
200
+ if (operator === 'exists' || operator === 'contains' || operator === 'like')
201
+ continue;
202
+ const read = Array.isArray(operand) ? operand.map((one) => coerced(kind, one)) : coerced(kind, operand);
203
+ operators[operator] = read;
204
+ const values = Array.isArray(read) ? read.map((one, index) => [one, [operator, index]]) : [[read, [operator]]];
205
+ for (const [value, rest] of values) {
206
+ if (value === null)
207
+ continue;
208
+ if (typeof value === 'string' && (kind === 'email' || (kind === 'datetime' && isoMoment.test(value) && defaultKinds.date.accepts(value.slice(0, 10)))))
209
+ continue;
210
+ const nested = segments.reduceRight((inner, segment) => ({ [segment]: inner }), value);
211
+ const { issues: refused = [] } = await owner.rowShape['~standard'].validate(nested);
212
+ const own = refused.find(({ path = [] }) => segments.every((segment, index) => {
213
+ const held = path[index];
214
+ return (typeof held === 'object' ? held.key : held) === segment;
215
+ }));
216
+ if (own)
217
+ issues.push({ message: own.message, path: [...at, key, ...rest] });
218
+ }
219
+ }
220
+ }
221
+ return issues;
222
+ };
223
+ // `createdAt` first, then column order. A kayan with no sortable candidate is refused as
224
+ // the caller's 400: the store would order nothing and pages would shift.
225
+ const sortCandidates = (kayan) => {
226
+ const rest = sortableFields(kayan);
227
+ const candidates = rest.includes('createdAt')
228
+ ? ['createdAt', ...rest.filter((name) => name !== 'createdAt')]
229
+ : rest;
230
+ if (!candidates.length) {
231
+ throw invalid({
232
+ message: `Kayan "${kayan.name}" has no field the store can sort by, so a read that names no sort has no order to read it in.`,
233
+ key: 'error.invalid.no_sortable_field',
234
+ params: { kayan: kayan.name },
235
+ });
236
+ }
237
+ return candidates;
238
+ };
239
+ // The id where it is a candidate. Its field access is never asked, so every caller of this
240
+ // is one whose order the caller cannot observe or is already the id.
241
+ const leastOrder = (candidates, direction) => ({
242
+ field: candidates.includes('id') ? 'id' : candidates[0],
243
+ direction,
244
+ });
245
+ const sortOf = async (kayan, sort, actor, authorize, admin) => {
246
+ if (sort === undefined) {
247
+ const candidates = sortCandidates(kayan);
248
+ // A denied candidate gives way to the next instead of refusing: ordering by a denied
249
+ // `createdAt` would leak the creation order of every readable row.
250
+ let denial;
251
+ for (const field of candidates) {
252
+ try {
253
+ await protectedField(kayan, field, actor, authorize, admin, 'sort');
254
+ }
255
+ catch (thrown) {
256
+ // Only a 403 falls through; any other throw is the project's failure.
257
+ if (!(thrown instanceof AsasError) || thrown.status !== 403)
258
+ throw thrown;
259
+ denial = thrown;
260
+ continue;
261
+ }
262
+ return { field, direction: 'desc' };
263
+ }
264
+ // Every candidate denied keeps the page in `leastOrder`; where some field is readable but
265
+ // none sortable, the last denial stands.
266
+ for (const name of Object.keys(kayan.fields)) {
267
+ if (candidates.includes(name))
268
+ continue;
269
+ const read = kayan.fields[name].access?.read;
270
+ if (denial && (!read || await read({ user: actor })))
271
+ throw denial;
272
+ }
273
+ // The kept order is the id or nothing: every other candidate carries the order just refused.
274
+ if (denial && !candidates.includes('id'))
275
+ throw denial;
276
+ return leastOrder(candidates, 'desc');
277
+ }
278
+ if (typeof sort !== 'string' || !/^-?[A-Za-z0-9_]+$/.test(sort)) {
279
+ throw invalid({ message: 'Managed read option "sort" must be a field name, optionally prefixed with "-".', key: 'error.invalid.sort' });
280
+ }
281
+ const direction = sort.startsWith('-') ? 'desc' : 'asc';
282
+ const field = sort.replace(/^-/, '');
283
+ // Reaches the unknown-field 400, the field-access 403 and the localized 400 from a sort.
284
+ await protectedField(kayan, field, actor, authorize, admin, 'sort');
285
+ return { field, direction };
286
+ };
287
+ export const readers = ({ kayan, store, cap, defaultLimit, joinLimit, admin, authorize, kayans, of, audit, catalog = [], openTenant, tenantOf, pickers = {}, }) => {
288
+ // Every managed read reaches the store here, so the soft-delete filter is put on once.
289
+ const alive = aliveWhere(kayan);
290
+ // A draft read reads published rows and the drafts `findVersions` admits, so no actor
291
+ // reads a draft its `findVersions` leaves out.
292
+ const shown = publishedWhere(kayan);
293
+ const findStore = (name, args, tx, drafts) => store.find({ kayan: name, args: { ...args, where: combineWhere(args.where, drafts ? combineWhere(alive, drafts.where && { or: [shown, drafts.where] }) : shown) }, tx });
294
+ /** The `join` fields of this kayan, which is what a depth populates. */
295
+ const joins = Object.values(kayan.fields).filter((field) => field.kind === 'join');
296
+ // Through `tools.kayans`, so the child's own access, masking and soft-delete filter hold.
297
+ // One query per join field per row.
298
+ const populateJoins = async (rows, depth) => {
299
+ if (depth < 1 || !joins.length)
300
+ return rows;
301
+ // A copy: a store may hand back the objects it holds, and these were masked for this actor.
302
+ rows = rows.map((row) => ({ ...row }));
303
+ const reads = kayans('read');
304
+ for (const row of rows) {
305
+ for (const field of joins) {
306
+ const child = reads[field.target.name];
307
+ // A child whose `find` is disabled leaves the key absent, not an empty page meaning "no children".
308
+ if (!child?.find)
309
+ continue;
310
+ const found = await child.find({
311
+ depth: depth - 1,
312
+ // A bound, not `limit: 0`: a page of parents would otherwise pull every child of each.
313
+ limit: field.defaultLimit ?? joinLimit,
314
+ // Without one, the child's own order holds.
315
+ sort: field.defaultSort,
316
+ where: { [field.on]: { equals: row.id } },
317
+ });
318
+ row[field.name] = found.docs;
319
+ }
320
+ }
321
+ return rows;
322
+ };
323
+ const currentTx = () => {
324
+ const outer = running.getStore();
325
+ const joined = outer?.transactions?.get(store);
326
+ if (joined?.settled) {
327
+ throw new Error(`Kayan "${kayan.name}" read joined the transaction "${joined.operation}" opened, which has already settled, so the read that started this one was not awaited.`);
328
+ }
329
+ return joined?.tx;
330
+ };
331
+ // A snapshot's organization is never its row's, so a version reads, and is masked, as its row's own.
332
+ const ownedAs = (snapshot, row) => kayan.tenant ? { ...snapshot, [kayan.tenant]: row?.[kayan.tenant] } : snapshot;
333
+ const ownedVersions = async (docs) => {
334
+ if (!kayan.tenant || !docs.length)
335
+ return docs;
336
+ const parents = await store.find({ kayan: kayan.name, args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { id: { in: [...new Set(docs.map((one) => one.parent))] } } }, tx: currentTx() });
337
+ const rows = new Map(parents.docs.map((row) => [String(row.id), row]));
338
+ return docs.map((one) => ({ ...one, version: ownedAs(one.version, rows.get(String(one.parent))) }));
339
+ };
340
+ // Only a row `findVersions` admits is swapped, so another reads as its published copy.
341
+ const latestOf = async (rows, granted) => {
342
+ const ids = rows.map((row) => row.id);
343
+ const admitted = granted.where && ids.length
344
+ ? (await store.find({ kayan: kayan.name, args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: combineWhere({ id: { in: ids } }, granted.where) }, tx: currentTx() })).docs.map((row) => row.id)
345
+ : ids;
346
+ if (!admitted.length)
347
+ return rows;
348
+ const found = await store.find({ kayan: kayan.versions.table, args: {
349
+ limit: 0,
350
+ page: 1,
351
+ sort: { field: 'updatedAt', direction: 'desc' },
352
+ where: { and: [{ parent: { in: admitted } }, { latest: { equals: true } }] },
353
+ }, tx: currentTx() });
354
+ const drafts = new Map(found.docs.map((one) => [String(one.parent), revived(kayan, one.version)]));
355
+ return rows.map((row) => {
356
+ const draft = drafts.get(String(row.id));
357
+ return draft ? ownedAs(draft, row) : row;
358
+ });
359
+ };
360
+ /** One paginated page, for a read of rows and a read of versions alike. */
361
+ const pageOf = (docs, totalDocs, limit, page) => {
362
+ const totalPages = limit > 0 ? Math.max(1, Math.ceil(totalDocs / limit)) : 1;
363
+ return {
364
+ docs,
365
+ hasNextPage: page < totalPages,
366
+ hasPrevPage: page > 1,
367
+ limit,
368
+ nextPage: page < totalPages ? page + 1 : null,
369
+ page,
370
+ pagingCounter: limit > 0 ? (page - 1) * limit + 1 : 1,
371
+ prevPage: page > 1 ? page - 1 : null,
372
+ totalDocs,
373
+ totalPages,
374
+ };
375
+ };
376
+ // Under `tenant: 'mine'` each row is masked for the membership it came through, so a filter
377
+ // and an order are held to every admitted membership's field access, or one would narrow
378
+ // or order another organization's rows by a column hidden from it.
379
+ const judges = (actor, accessWhere) => membersAdmitted(running.getStore()?.tenant, accessWhere) ?? [actor];
380
+ const judgedWhere = async (where, actor, accessWhere) => {
381
+ let judged;
382
+ for (const judge of judges(actor, accessWhere)) {
383
+ judged = combineWhere(judged, await assertReadableWhere({ kayan, where, actor: judge, authorize, admin }));
384
+ }
385
+ return judged;
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
+ };
400
+ const fetchList = async (input, actor, granted, drafts) => {
401
+ const accessWhere = granted ? granted.where : await readAccess(kayan, actor, authorize, admin);
402
+ const callerWhere = await judgedWhere(input.where, actor, accessWhere);
403
+ const where = combineWhere(combineWhere(callerWhere, accessWhere), await offeredFor(input.for, actor));
404
+ const limit = integerOption('limit', input.limit, defaultLimit, 0);
405
+ const page = integerOption('page', input.page, 1, 1);
406
+ const [first, ...rest] = judges(actor, accessWhere);
407
+ const sort = await sortOf(kayan, input.sort, first, authorize, admin);
408
+ for (const judge of rest)
409
+ if (sort.field !== 'id')
410
+ await protectedField(kayan, sort.field, judge, authorize, admin, 'sort');
411
+ const result = await findStore(kayan.name, { limit, page, sort, where }, currentTx(), drafts);
412
+ const rows = drafts ? await latestOf(result.docs, drafts) : result.docs;
413
+ return pageOf(await populateJoins(rows, integerOption('depth', input.depth, 0, 0, cap - 1)), result.totalDocs, limit, page);
414
+ };
415
+ const fetchOne = async (input, actor, granted, drafts) => {
416
+ const id = input.id;
417
+ const accessWhere = granted ? granted.where : await readAccess(kayan, actor, authorize, admin, id);
418
+ // The tenancy filter, where the language declares one, selects the tenant's own row.
419
+ assertSingleConstrained(kayan, accessWhere);
420
+ const where = kayan.single ? accessWhere : combineWhere({ id: { equals: id } }, accessWhere);
421
+ // Only a global's order picks its row; elsewhere an id equality at limit 1 does, and
422
+ // running the sort field's callbacks could only turn a readable row into a 403.
423
+ const sort = kayan.single
424
+ ? await sortOf(kayan, undefined, actor, authorize, admin)
425
+ : leastOrder(sortCandidates(kayan), 'asc');
426
+ const result = await findStore(kayan.name, { limit: 1, page: 1, sort, where }, currentTx(), drafts);
427
+ const found = result.docs.slice(0, 1);
428
+ const [row] = await populateJoins(drafts ? await latestOf(found, drafts) : found, integerOption('depth', input.depth, 0, 0, cap - 1));
429
+ return row ?? null;
430
+ };
431
+ const fetchCount = async (input, actor, granted) => {
432
+ const callerWhere = await judgedWhere(input.where, actor, granted.where);
433
+ const where = combineWhere(callerWhere, granted.where);
434
+ if (store.count)
435
+ return { totalDocs: await store.count({ kayan: kayan.name, args: { where: combineWhere(where, shown) }, tx: currentTx() }) };
436
+ // A total is the same in any order.
437
+ const found = await findStore(kayan.name, { limit: 0, page: 1, sort: leastOrder(sortCandidates(kayan), 'asc'), where }, currentTx());
438
+ return { totalDocs: found.totalDocs };
439
+ };
440
+ // A version is read only where its row is readable under the row's own access, across tenants included.
441
+ const versionsWithin = (granted) => {
442
+ const rows = combineWhere(granted.where, alive);
443
+ return rows ? prefixWhere(kayan.name, 'parent', rows) : undefined;
444
+ };
445
+ // A version's snapshot is no column a filter reaches, and a hop through `parent` would
446
+ // ask about a row's columns past its field access.
447
+ const versionsQuery = async (where) => {
448
+ if (where === undefined)
449
+ return undefined;
450
+ const table = kayan.versions.kayan;
451
+ const hops = (one) => !!one && typeof one === 'object' && Object.entries(one).some(([key, value]) => key === 'and' || key === 'or' ? Array.isArray(value) && value.some(hops) : key.includes('.'));
452
+ let checked;
453
+ try {
454
+ if (hops(where))
455
+ throw new Error(`Kayan "${table.name}" query names a path, and versions are filtered by their own columns alone: id, parent, latest, autosave, createdAt and updatedAt.`);
456
+ checked = assertWhereGrammar(table, where);
457
+ }
458
+ catch (thrown) {
459
+ if (!(thrown instanceof Error))
460
+ throw thrown;
461
+ throw invalid({ message: thrown.message, key: 'error.invalid.where', params: { kayan: kayan.name, where }, cause: thrown });
462
+ }
463
+ // Each value held to its column's kind, as a read of the rows holds it.
464
+ return valuesChecked(table, checked);
465
+ };
466
+ const versionsSort = (sort) => {
467
+ if (sort === undefined)
468
+ return { field: 'updatedAt', direction: 'desc' };
469
+ if (typeof sort !== 'string' || !/^-?(createdAt|updatedAt)$/.test(sort)) {
470
+ throw invalid({ message: 'Managed read option "sort" on versions must be "createdAt" or "updatedAt", optionally prefixed with "-".', key: 'error.invalid.sort' });
471
+ }
472
+ return { field: sort.replace(/^-/, ''), direction: sort.startsWith('-') ? 'desc' : 'asc' };
473
+ };
474
+ const fetchVersions = async (input, granted) => {
475
+ const limit = integerOption('limit', input.limit, defaultLimit, 0);
476
+ const page = integerOption('page', input.page, 1, 1);
477
+ const where = combineWhere(await versionsQuery(input.where), versionsWithin(granted));
478
+ const result = await store.find({ kayan: kayan.versions.table, args: { limit, page, sort: versionsSort(input.sort), where }, tx: currentTx() });
479
+ return pageOf(await ownedVersions(result.docs), result.totalDocs, limit, page);
480
+ };
481
+ const fetchVersion = async (input, granted) => {
482
+ const where = combineWhere({ id: { equals: input.id } }, versionsWithin(granted));
483
+ const [one] = await ownedVersions((await store.find({ kayan: kayan.versions.table, args: { limit: 1, page: 1, sort: versionsSort(undefined), where }, tx: currentTx() })).docs);
484
+ return one ?? null;
485
+ };
486
+ const operation = ({ name, declaration, input, output, draftOutput }) => async (data = {}, options = {}) => {
487
+ const outer = running.getStore();
488
+ const actor = outer?.user ?? options.actor ?? null;
489
+ const list = atEffect(declaration.steps);
490
+ const chain = [...(outer?.context.chain ?? []), `${kayan.name}.${name}`];
491
+ const depth = (outer?.context.depth ?? 0) + 1;
492
+ // The cap stops a read step that calls this operation again, as it stops a write.
493
+ if (depth > cap) {
494
+ throw new Error(`Read depth ${depth} is past the cap of ${cap}: ${chain.join(' -> ')}.`);
495
+ }
496
+ // A nested read takes the outer context, never the caller's: a read under a write is that write's.
497
+ const context = Object.freeze({ source: 'local', ...(outer?.context ?? options.context), depth, chain });
498
+ const mode = declaration.mode ?? 'many';
499
+ // Opened by the outermost read alone, so a call under it can never widen what it reaches.
500
+ const tenant = outer ? outer.tenant : await openTenant({ actor, reach: options.tenant, kayan });
501
+ const around = { context, ...(outer && { transactions: outer.transactions }), user: actor, ...(tenant && { tenant }) };
502
+ // Asked inside the scope, which the tenant answer reads.
503
+ const granted = await running.run(around, async () => ({
504
+ where: await readAccess(kayan, actor, authorize, admin, mode === 'one' ? data.id : undefined, name),
505
+ }));
506
+ // A draft read answers each row's latest version, so it asks `findVersions` too.
507
+ const drafts = !!options.draft && !!kayan.versions?.drafts && (mode === 'many' || mode === 'one')
508
+ ? await running.run(around, async () => ({ where: await authorize({ admin, actor, data: {}, effect: 'read', kayan, operation: 'findVersions' }) }))
509
+ : undefined;
510
+ // Each row is masked for the membership it came through under `tenant: 'mine'`; a row of none reads as no one's.
511
+ const reached = membersAdmitted(tenant, granted.where);
512
+ const viewer = (row) => reached && kayan.tenant ? (tenant.members.get(String(row[kayan.tenant])) ?? null) : actor;
513
+ const tools = Object.freeze({
514
+ operation: name,
515
+ effect: 'read',
516
+ kind: mode,
517
+ schema: input,
518
+ output,
519
+ publicOutput: declaration.output,
520
+ kayans: kayans('read'),
521
+ of,
522
+ audit,
523
+ catalog,
524
+ read: (values) => {
525
+ const final = values;
526
+ return mode === 'one'
527
+ ? fetchOne(final, actor, final.id === data.id ? granted : undefined, drafts)
528
+ : mode === 'count'
529
+ ? fetchCount(final, actor, granted)
530
+ : mode === 'versions'
531
+ ? fetchVersions(final, granted)
532
+ : mode === 'version'
533
+ ? fetchVersion(final, granted)
534
+ : fetchList(final, actor, granted, drafts);
535
+ },
536
+ });
537
+ const tx = currentTx();
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 };
541
+ const run = async () => {
542
+ // A read fills no default: it writes no column.
543
+ ctx = await parse({ schema: input, ctx, tools });
544
+ // `strictFunctionTypes` checks the actor contravariantly, hence the one cast. A read runs
545
+ // no service-target list: it opens no transaction, and `audit` is taken only by naming it.
546
+ for (const step of atEffect([...list, ...declaration.afterSteps]))
547
+ ctx = { ...(await step(ctx, tools)), tenant: owner };
548
+ };
549
+ await running.run(around, run);
550
+ // A total carries no column, so nothing to mask.
551
+ if (mode === 'count') {
552
+ const totalDocs = ctx.data?.totalDocs;
553
+ if (typeof totalDocs !== 'number')
554
+ throw new Error(`Kayan "${kayan.name}" operation "${name}" must return { totalDocs }.`);
555
+ return { totalDocs };
556
+ }
557
+ // Cut to what this actor may read of the row, and held to its kinds through the draft shape.
558
+ const versionOf = async (one) => ({
559
+ id: one.id,
560
+ parent: one.parent,
561
+ latest: one.latest,
562
+ autosave: one.autosave,
563
+ createdAt: one.createdAt,
564
+ updatedAt: one.updatedAt,
565
+ version: await publicResult({ kayan, row: revived(kayan, one.version), actor: viewer(revived(kayan, one.version)), internalOutput: draftOutput ?? output }),
566
+ });
567
+ // An absent or hidden row addressed by id is a 404; a global addressed by no id answers `null` before its first save.
568
+ if (ctx.data === null && (mode === 'version' || (mode === 'one' && !kayan.single))) {
569
+ throw missing({
570
+ message: `No "${kayan.name}" ${mode === 'version' ? 'version' : 'row'} with id ${String(data.id)}.`,
571
+ key: 'error.not_found.row',
572
+ params: { kayan: kayan.name, id: data.id, operation: name },
573
+ });
574
+ }
575
+ if (mode === 'version')
576
+ return versionOf(ctx.data);
577
+ if (mode === 'versions') {
578
+ const found = ctx.data;
579
+ const docs = [];
580
+ for (const one of found.docs)
581
+ docs.push(await versionOf(one));
582
+ return { ...found, docs };
583
+ }
584
+ // A draft may be short of `required`, so it reads through the draft shape.
585
+ const rowOutput = drafts ? draftOutput : output;
586
+ if (mode === 'one') {
587
+ if (ctx.data === null)
588
+ return null;
589
+ return publicResult({ kayan, row: ctx.data, actor: viewer(ctx.data), internalOutput: rowOutput, publicOutput: tools.publicOutput });
590
+ }
591
+ const page = ctx.data;
592
+ if (!page || typeof page !== 'object' || !Array.isArray(page.docs)) {
593
+ throw new Error(`Kayan "${kayan.name}" operation "${name}" must return a managed list result.`);
594
+ }
595
+ const docs = [];
596
+ for (const row of page.docs)
597
+ docs.push(await publicResult({ kayan, row, actor: viewer(row), internalOutput: rowOutput, publicOutput: tools.publicOutput }));
598
+ return { ...page, docs };
599
+ };
600
+ return { operation };
601
+ };
@@ -0,0 +1,21 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { Store } from '../../mithaq/language.ts';
3
+ import type { Context } from './steps.ts';
4
+ import type { TenantScope } from './tenant.ts';
5
+ export type Open = {
6
+ tx: unknown;
7
+ operation: string;
8
+ settled?: true;
9
+ afterCommit?: (() => Promise<void>)[];
10
+ };
11
+ export type Running<Actor = unknown> = {
12
+ context: Context;
13
+ transactions?: Map<Store, Open>;
14
+ user: Actor | null;
15
+ tenant?: TenantScope;
16
+ };
17
+ export declare const running: AsyncLocalStorage<Running<unknown>>;
18
+ export declare const firstRegisters: WeakSet<Open>;
19
+ export declare const snapshot: (row: unknown) => Readonly<{
20
+ [x: string]: unknown;
21
+ }> | undefined;
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export const running = new AsyncLocalStorage();
3
+ // Transactions local auth's first-register opened after reading its table empty
4
+ // under an exclusive lock; the one create inside skips `create` access.
5
+ export const firstRegisters = new WeakSet();
6
+ export const snapshot = (row) => row && typeof row === 'object' && !Array.isArray(row)
7
+ ? Object.freeze({ ...row })
8
+ : undefined;