@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.
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// One list in one place, so drizzle's callback transaction is kept as it is.
|
|
2
|
+
import { stepTools } from './store.js';
|
|
2
3
|
import { rowsOfField } from '../../mithaq/rows.js';
|
|
3
4
|
import { heldPermissions } from '../../rbac/index.js';
|
|
4
5
|
import { authActor } from '../actor.js';
|
|
5
|
-
import { AfterCommitError, AsasError, denied, invalid, missing } from './errors.js';
|
|
6
|
-
import { defaultKinds } from '../../base/kinds.js';
|
|
6
|
+
import { AfterCommitError, AsasError, conflict, denied, invalid, missing, retryConflict } from './errors.js';
|
|
7
|
+
import { defaultKinds, isStorableText } from '../../base/kinds.js';
|
|
7
8
|
import { providerOf } from '../../base/rules.js';
|
|
8
9
|
import { atEffect, defaults, issueRefusal, parse, sealOf, storeSeals, validated, withoutPrototype } from './steps.js';
|
|
9
10
|
import { firstRegisters, running, snapshot } from './runtime.js';
|
|
@@ -21,6 +22,9 @@ const known = new Set(operatorList);
|
|
|
21
22
|
/** A list of operators as a sentence says it: `"a", "b" and "c"`. */
|
|
22
23
|
const and = new Intl.ListFormat('en-GB', { type: 'conjunction' });
|
|
23
24
|
const checkOperatorValue = (said, field, operator, value) => {
|
|
25
|
+
if ([value].flat().some((one) => typeof one === 'string' && !isStorableText(one))) {
|
|
26
|
+
throw new Error(`${said} field "${field}" operator "${operator}" compares text holding a NUL or half a surrogate pair, which no column holds.`);
|
|
27
|
+
}
|
|
24
28
|
if (operator === 'exists') {
|
|
25
29
|
if (typeof value !== 'boolean')
|
|
26
30
|
throw new Error(`${said} field "${field}" operator "exists" needs a boolean.`);
|
|
@@ -44,25 +48,44 @@ const checkOperatorValue = (said, field, operator, value) => {
|
|
|
44
48
|
};
|
|
45
49
|
// A second segment is a locale or a related field, never both: `resolve` refuses
|
|
46
50
|
// `localized` beside `relationTo` on the field itself, whatever the kind is named.
|
|
51
|
+
// A json field's path may run past two segments: its keys are no columns, and Payload names them so.
|
|
47
52
|
const queryField = ({ kayan, key, said }) => {
|
|
48
53
|
const parts = key.split('.');
|
|
49
|
-
const unsupported = `${said} path "${key}" is unsupported. A path is one field, and may then name one related field of what a relationship points at or one locale of a localized field, so it is never more than two segments.`;
|
|
50
|
-
if (parts.length > 2 || parts.some((part) => !part))
|
|
51
|
-
throw new Error(unsupported);
|
|
54
|
+
const unsupported = `${said} path "${key}" is unsupported. A path is one field, and may then name one related field of what a relationship points at or one locale of a localized field, so it is never more than two segments, or a key at each segment after a json field.`;
|
|
52
55
|
const field = parts[0];
|
|
53
|
-
const path = parts[1];
|
|
54
56
|
const resolved = kayan.fields[field];
|
|
55
|
-
if (
|
|
57
|
+
if ((parts.length > 2 && resolved?.kind !== 'json') || parts.some((part) => !part || !isStorableText(part)))
|
|
58
|
+
throw new Error(unsupported);
|
|
59
|
+
const path = parts[1];
|
|
60
|
+
if (!resolved || !Object.hasOwn(kayan.fields, field))
|
|
56
61
|
throw new Error(`${said} names unknown field "${key}".`);
|
|
62
|
+
if (resolved.kind === 'json')
|
|
63
|
+
return { field, key, kind: resolved.kind, operators: resolved.operators };
|
|
57
64
|
if (!path || resolved.locales?.includes(path))
|
|
58
65
|
return { field, key, kind: resolved.kind, operators: resolved.operators };
|
|
59
66
|
if (!resolved.target)
|
|
60
67
|
throw new Error(unsupported);
|
|
61
68
|
const target = resolved.target.fields[path];
|
|
62
|
-
if (!target)
|
|
69
|
+
if (!target || !Object.hasOwn(resolved.target.fields, path))
|
|
63
70
|
throw new Error(`${said} path "${key}" names unknown related field.`);
|
|
71
|
+
// A path into json across a hop would be a third segment, and a json value compared whole has no store answer.
|
|
72
|
+
if (target.kind === 'json')
|
|
73
|
+
throw new Error(`${said} path "${key}" reaches json field "${path}" across a relationship, and a json field is filtered on its own kayan alone.`);
|
|
64
74
|
return { field, key, kind: target.kind, operators: target.operators };
|
|
65
75
|
};
|
|
76
|
+
/** Raw store filters keep their adapter's value and empty-filter semantics; only declared paths are checked here. */
|
|
77
|
+
export const assertStoreWhere = (kayan, where) => {
|
|
78
|
+
for (const [key, value] of Object.entries(where)) {
|
|
79
|
+
if (key === 'and' || key === 'or') {
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
for (const one of value)
|
|
82
|
+
assertStoreWhere(kayan, one);
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
queryField({ kayan, key, said: `Kayan "${kayan.name}" store filter` });
|
|
86
|
+
}
|
|
87
|
+
return where;
|
|
88
|
+
};
|
|
66
89
|
// An access constraint and a screen's `when` share this one grammar.
|
|
67
90
|
export const assertFieldWhere = ({ said, key, kind, operators, value, valued = true }) => {
|
|
68
91
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -92,6 +115,13 @@ export const assertFieldWhere = ({ said, key, kind, operators, value, valued = t
|
|
|
92
115
|
throw new Error(`${said} field "${key}" operator "${operator}" only supports numeric, date and datetime fields.`);
|
|
93
116
|
}
|
|
94
117
|
checkOperatorValue(said, key, operator, operand);
|
|
118
|
+
if (!valued && Array.isArray(operand) && operand.length > whereLimits.values) {
|
|
119
|
+
throw new Error(`${said} field "${key}" operator "${operator}" lists at most ${whereLimits.values} values.`);
|
|
120
|
+
}
|
|
121
|
+
// Each word of a `like` is a pattern the store matches, so its words are held to the same count.
|
|
122
|
+
if (!valued && operator === 'like' && typeof operand === 'string' && operand.trim().split(/\s+/).length > whereLimits.values) {
|
|
123
|
+
throw new Error(`${said} field "${key}" operator "like" holds at most ${whereLimits.values} words.`);
|
|
124
|
+
}
|
|
95
125
|
// A value an integer or decimal column cannot hold would reach Postgres as a 500.
|
|
96
126
|
// A caller's own query skips this: its row shape refuses values with a path each.
|
|
97
127
|
if (valued && (kind === 'integer' || kind === 'decimal') && operator !== 'exists') {
|
|
@@ -110,7 +140,13 @@ export const assertFieldWhere = ({ said, key, kind, operators, value, valued = t
|
|
|
110
140
|
export const assertWhere = (kayan, where) => normalizedWhere({ kayan, where, valued: true, said: `Kayan "${kayan.name}" access constraint` });
|
|
111
141
|
// Grammar alone: a caller's values are the row shape's to refuse.
|
|
112
142
|
export const assertWhereGrammar = (kayan, where) => normalizedWhere({ kayan, where, valued: false, said: `Kayan "${kayan.name}" query` });
|
|
113
|
-
|
|
143
|
+
/**
|
|
144
|
+
* How large a caller's `where` may be: the values one `in` or `not_in` lists, and the fields, `and`s and
|
|
145
|
+
* `or`s it holds in all, however nested. Each is a condition the store runs, so past these a read is
|
|
146
|
+
* refused before it reaches the store rather than holding it for minutes.
|
|
147
|
+
*/
|
|
148
|
+
export const whereLimits = { values: 1000, nodes: 200 };
|
|
149
|
+
const normalizedWhere = ({ kayan, where, valued, said, nodes = { left: whereLimits.nodes } }) => {
|
|
114
150
|
if (!where || typeof where !== 'object' || Array.isArray(where)) {
|
|
115
151
|
throw new Error(`${said} is not an object of fields.`);
|
|
116
152
|
}
|
|
@@ -120,11 +156,14 @@ const normalizedWhere = ({ kayan, where, valued, said }) => {
|
|
|
120
156
|
// No prototype, so `__proto__` is a key like any other.
|
|
121
157
|
const normalized = Object.create(null);
|
|
122
158
|
for (const [key, value] of entries) {
|
|
159
|
+
// An access constraint is the project's own to size; a caller's is held to the limits.
|
|
160
|
+
if (!valued && --nodes.left < 0)
|
|
161
|
+
throw new Error(`${said} holds at most ${whereLimits.nodes} fields, "and"s and "or"s.`);
|
|
123
162
|
if (key === 'and' || key === 'or') {
|
|
124
163
|
if (!Array.isArray(value) || !value.length) {
|
|
125
164
|
throw new Error(`${said} "${key}" must be a non-empty array.`);
|
|
126
165
|
}
|
|
127
|
-
normalized[key] = value.map((one) => normalizedWhere({ kayan, where: one, valued, said }));
|
|
166
|
+
normalized[key] = value.map((one) => normalizedWhere({ kayan, where: one, valued, said, nodes }));
|
|
128
167
|
continue;
|
|
129
168
|
}
|
|
130
169
|
const queried = queryField({ kayan, key, said });
|
|
@@ -138,6 +177,111 @@ const normalizedWhere = ({ kayan, where, valued, said }) => {
|
|
|
138
177
|
export const aliveWhere = (kayan) => kayan.softDelete ? { [kayan.softDelete]: { exists: false } } : undefined;
|
|
139
178
|
// A row made as a draft and never published is read by nothing but a draft read.
|
|
140
179
|
export const publishedWhere = (kayan) => combineWhere(aliveWhere(kayan), kayan.versions?.drafts ? { _status: { equals: 'published' } } : undefined);
|
|
180
|
+
/** Every field of `kayans` whose `onDelete` keeps a row of `target` while it names it. */
|
|
181
|
+
export const restrictingPointers = (kayans, target) => kayans.flatMap((kayan) => Object.values(kayan.fields)
|
|
182
|
+
.filter((field) => field.kind !== 'join' && field.target?.name === target.name && field.rules?.onDelete === 'restrict')
|
|
183
|
+
.map((field) => ({ kayan, field })));
|
|
184
|
+
/** How many of the rows that still name a row the refusal names by title; its count says the rest. */
|
|
185
|
+
const namedUses = 5;
|
|
186
|
+
const byId = { field: 'id', direction: 'asc' };
|
|
187
|
+
/**
|
|
188
|
+
* The ids of the rows of each kayan that name `id` through `pointers`, a row whose latest version
|
|
189
|
+
* names it among them, since the foreign key holds live rows alone and a pending draft would
|
|
190
|
+
* otherwise keep an id nothing answers for. A pointing kayan that is tenant-owned is read within
|
|
191
|
+
* `tenant` when one is given, so a count never reaches another organization's rows.
|
|
192
|
+
*/
|
|
193
|
+
export const referencingIds = async ({ id, pointers, store, tenant, tx }) => {
|
|
194
|
+
const uses = new Map();
|
|
195
|
+
for (const { kayan, field } of pointers) {
|
|
196
|
+
const ids = uses.get(kayan) ?? new Set();
|
|
197
|
+
const within = (path) => (kayan.tenant && tenant !== undefined ? { [path]: { equals: tenant } } : undefined);
|
|
198
|
+
const rows = await store.find({ kayan: kayan.name, args: { limit: 0, page: 1, sort: byId, where: combineWhere({ [field.name]: { equals: id } }, within(kayan.tenant)) }, tx });
|
|
199
|
+
for (const row of rows.docs)
|
|
200
|
+
ids.add(String(row.id));
|
|
201
|
+
if (kayan.versions) {
|
|
202
|
+
// ponytail: `version.<field>` is read with no index, a scan of the versions table per delete;
|
|
203
|
+
// index it once a delete is measured slow.
|
|
204
|
+
const versions = await store.find({ kayan: kayan.versions.table, args: { limit: 0, page: 1, sort: byId, where: combineWhere({ and: [{ latest: { equals: true } }, { [`version.${field.name}`]: { equals: id } }] }, within(`version.${kayan.tenant}`)) }, tx });
|
|
205
|
+
for (const version of versions.docs)
|
|
206
|
+
ids.add(String(version.parent));
|
|
207
|
+
}
|
|
208
|
+
if (ids.size)
|
|
209
|
+
uses.set(kayan, ids);
|
|
210
|
+
}
|
|
211
|
+
return uses;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Refuses the delete while a live row or the latest version of a row names it, in the delete's own
|
|
215
|
+
* transaction. The delete holds the row `FOR UPDATE`, and a write naming it holds it `FOR KEY SHARE`
|
|
216
|
+
* in `assertReferences`, a draft save included. The two conflict, so no new reference lands between
|
|
217
|
+
* this count and the delete. An update holds its own row as `rowLock` says, which `FOR KEY SHARE` passes
|
|
218
|
+
* unless the update names a unique column. The refusal counts the actor's own
|
|
219
|
+
* organization's rows and names by title only those the actor reads; a row another organization's
|
|
220
|
+
* rows name is refused with no count at all.
|
|
221
|
+
*/
|
|
222
|
+
const refuseReferenced = async ({ kayan, existing, pointers, store, tenant, tx, authorize, admin, actor }) => {
|
|
223
|
+
const counted = async (within) => {
|
|
224
|
+
const found = await referencingIds({ id: existing.id, pointers, store, tenant: within, tx });
|
|
225
|
+
// A row that names itself goes with itself; read off the stored row, since the caller's id may differ in case.
|
|
226
|
+
found.get(kayan)?.delete(String(existing.id));
|
|
227
|
+
return { found, total: [...found.values()].reduce((sum, ids) => sum + ids.size, 0) };
|
|
228
|
+
};
|
|
229
|
+
// Every organization's rows gate the delete, since the foreign key holds no draft; only the
|
|
230
|
+
// actor's own are counted and named to it.
|
|
231
|
+
const all = await counted(undefined);
|
|
232
|
+
if (!all.total)
|
|
233
|
+
return;
|
|
234
|
+
const own = tenant === undefined ? all : await counted(tenant);
|
|
235
|
+
if (own.total !== all.total) {
|
|
236
|
+
throw conflict({
|
|
237
|
+
message: `A "${kayan.name}" row is still named by rows this caller does not see, so it is not deleted.`,
|
|
238
|
+
key: 'error.conflict.referenced',
|
|
239
|
+
params: { kayan: kayan.name },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const uses = [];
|
|
243
|
+
for (const [one, ids] of own.found) {
|
|
244
|
+
if (ids.size)
|
|
245
|
+
uses.push({ by: one.name, count: ids.size, ...(await titlesOf({ kayan: one, ids: [...ids], store, tx, authorize, admin, actor })) });
|
|
246
|
+
}
|
|
247
|
+
throw conflict({
|
|
248
|
+
message: `A "${kayan.name}" row is still named by ${uses.map(({ by, count }) => `${count} "${by}" row${count === 1 ? '' : 's'}`).join(' and ')}, so it is not deleted.`,
|
|
249
|
+
key: 'error.conflict.referenced',
|
|
250
|
+
params: { kayan: kayan.name, by: uses[0].by, uses },
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* The titles of the rows among `ids` this actor finds and whose title field it reads, a draft only
|
|
255
|
+
* where it reads drafts. A title that points at another row is its raw id, so such rows are
|
|
256
|
+
* counted alone; a moment is sent as a value, marked so the screen formats it.
|
|
257
|
+
*/
|
|
258
|
+
const titlesOf = async ({ kayan, ids, store, tx, authorize, admin, actor }) => {
|
|
259
|
+
const title = Object.values(kayan.targets ?? {}).map((target) => target?.useAsTitle).find((one) => typeof one === 'string');
|
|
260
|
+
const field = title && Object.hasOwn(kayan.fields, title) ? kayan.fields[title] : undefined;
|
|
261
|
+
if (!field || field.target || field.kind === 'join')
|
|
262
|
+
return { titles: [] };
|
|
263
|
+
const asked = (operation) => authorize({ admin, actor, data: {}, effect: 'read', kayan, operation }).then((where) => ({ where }), (thrown) => {
|
|
264
|
+
if (thrown instanceof AsasError && thrown.status === 403)
|
|
265
|
+
return undefined;
|
|
266
|
+
throw thrown;
|
|
267
|
+
});
|
|
268
|
+
const readable = await asked('find');
|
|
269
|
+
if (!readable)
|
|
270
|
+
return { titles: [] };
|
|
271
|
+
const drafts = kayan.versions?.drafts ? await asked('findVersions') : undefined;
|
|
272
|
+
const shown = kayan.versions?.drafts && drafts
|
|
273
|
+
? combineWhere(aliveWhere(kayan), drafts.where && { or: [{ _status: { equals: 'published' } }, drafts.where] })
|
|
274
|
+
: publishedWhere(kayan);
|
|
275
|
+
const sort = field.rules?.localized ? byId : { field: field.name, direction: 'asc' };
|
|
276
|
+
const found = await store.find({ kayan: kayan.name, args: { limit: namedUses, page: 1, sort, where: { and: [{ id: { in: ids } }, readable.where, shown].filter(Boolean) } }, tx });
|
|
277
|
+
const titles = [];
|
|
278
|
+
for (const row of found.docs) {
|
|
279
|
+
const visible = await projectReadableFields({ kayan, row, actor });
|
|
280
|
+
if (Object.hasOwn(visible, field.name))
|
|
281
|
+
titles.push(visible[field.name]);
|
|
282
|
+
}
|
|
283
|
+
return { titles, ...(field.kind === 'date' || field.kind === 'datetime' ? { titleKind: field.kind } : {}) };
|
|
284
|
+
};
|
|
141
285
|
const fieldAccess = async (operation, kayan, data, actor, id, named, doc, kept = []) => {
|
|
142
286
|
const next = { ...data };
|
|
143
287
|
const refused = [];
|
|
@@ -227,12 +371,29 @@ export const assertSingleConstrained = (kayan, where) => {
|
|
|
227
371
|
throw new Error(`Global "${kayan.name}" keeps the tenant field, and the actor's access constrains no rows, so no one row is the tenant's own. Return a Where from its read and update access that constrains the rows to the actor's tenant.`);
|
|
228
372
|
}
|
|
229
373
|
};
|
|
230
|
-
|
|
374
|
+
/**
|
|
375
|
+
* The lock a write holds on a row it changes, as Postgres's own statement takes it: `FOR UPDATE` for a
|
|
376
|
+
* delete, or for an update naming a column of a unique index, which holds off a write naming the row
|
|
377
|
+
* `FOR KEY SHARE`, and `FOR NO KEY UPDATE` for any other update, which that write passes. A soft delete
|
|
378
|
+
* is an update of its mark, and asks no rows naming its row, so it has no reference to hold off. Taken
|
|
379
|
+
* whole when the row loads, before any value is compared: an UPDATE raising its lock later would wait on
|
|
380
|
+
* a writer that named the row in between while that writer waits on it, a deadlock.
|
|
381
|
+
*
|
|
382
|
+
* ponytail: publishing a latest draft's unique column can still raise this lock; take `update` for every
|
|
383
|
+
* non-draft update on a kayan with drafts and a unique index if that path needs to be covered.
|
|
384
|
+
* ponytail: an update step can raise its own row's lock by writing a unique column or hard-deleting the
|
|
385
|
+
* row; take `update` at load for an operation whose steps may do either if that path needs to be covered.
|
|
386
|
+
*/
|
|
387
|
+
export const rowLock = ({ kayan, kind, data }) => {
|
|
388
|
+
const written = kind === 'remove' || kind === 'deleteMany' ? (kayan.softDelete ? [kayan.softDelete] : undefined) : Object.keys(data);
|
|
389
|
+
return !written || kayan.indexes.some((index) => index.unique && index.fields.some((name) => written.includes(name))) ? 'update' : 'noKeyUpdate';
|
|
390
|
+
};
|
|
391
|
+
const loadExisting = async ({ kayan, store, id, where, tx, rows = aliveWhere(kayan), lock }) => {
|
|
231
392
|
assertSingleConstrained(kayan, where);
|
|
232
393
|
const found = await store.find({ kayan: kayan.name, args: {
|
|
233
394
|
limit: 1,
|
|
234
395
|
page: 1,
|
|
235
|
-
lock
|
|
396
|
+
lock,
|
|
236
397
|
// A write's own look at the row it changes, over a set an id or a constraint already reduced.
|
|
237
398
|
sort: { field: 'id', direction: 'asc' },
|
|
238
399
|
// A marked row is not there to change, so a second delete of it is the 404 an absent id gets.
|
|
@@ -251,6 +412,11 @@ const validateTenantRelationships = async (kayan, store, data, actor, existing,
|
|
|
251
412
|
tenant: own ? (data[own] ?? existing?.[own]) : undefined,
|
|
252
413
|
// Asked with no operation, so no permission key is asked for.
|
|
253
414
|
access: (target) => authorize({ admin, actor, data: {}, effect: 'read', kayan: target }),
|
|
415
|
+
// A field's `filterOptions` holds a save to what its picker offers, as Payload's does.
|
|
416
|
+
offered: async (field) => {
|
|
417
|
+
const answer = await field.filterOptions?.({ user: actor, tx });
|
|
418
|
+
return answer === undefined || answer === true ? undefined : answer === false ? { id: { in: [] } } : assertWhere(field.target, answer);
|
|
419
|
+
},
|
|
254
420
|
tx,
|
|
255
421
|
});
|
|
256
422
|
};
|
|
@@ -259,9 +425,12 @@ const validateTenantRelationships = async (kayan, store, data, actor, existing,
|
|
|
259
425
|
* organization than the written row's, or one `access` leaves out. A step's own raw write
|
|
260
426
|
* passes no `access`, so it is held to the organization alone.
|
|
261
427
|
*/
|
|
262
|
-
|
|
428
|
+
// Postgres answers a uuid in lowercase, so ids are compared in that case.
|
|
429
|
+
const keyIn = (kayan) => (value) => kayan.fields.id?.kind === 'uuid' && typeof value === 'string' ? value.toLowerCase() : value;
|
|
430
|
+
export const assertReferences = async ({ kayan, store, data, tenant, access, offered, tx }) => {
|
|
263
431
|
const own = kayan.tenant;
|
|
264
|
-
// Gathered per target, so two fields at one kayan and a whole `hasMany` set are one read
|
|
432
|
+
// Gathered per target, so two fields at one kayan and a whole `hasMany` set are one read; a
|
|
433
|
+
// field whose options are its own is read apart.
|
|
265
434
|
const targets = new Map();
|
|
266
435
|
for (const field of Object.values(kayan.fields)) {
|
|
267
436
|
// The ownership column is the runner's, or was judged above. A join takes no write.
|
|
@@ -270,13 +439,16 @@ export const assertReferences = async ({ kayan, store, data, tenant, access, tx
|
|
|
270
439
|
const given = data[field.name];
|
|
271
440
|
if (given === undefined || given === null)
|
|
272
441
|
continue;
|
|
442
|
+
const key = offered && field.filterOptions ? field : field.target;
|
|
273
443
|
for (const value of field.hasMany ? given : [given]) {
|
|
274
444
|
if (value === undefined || value === null)
|
|
275
445
|
continue;
|
|
276
|
-
|
|
446
|
+
const held = targets.get(key) ?? { target: field.target, sets: [] };
|
|
447
|
+
held.sets.push({ field: field.name, value });
|
|
448
|
+
targets.set(key, held);
|
|
277
449
|
}
|
|
278
450
|
}
|
|
279
|
-
for (const [target, sets] of targets) {
|
|
451
|
+
for (const [key, { target, sets }] of targets) {
|
|
280
452
|
const far = target.tenant;
|
|
281
453
|
// A raw write to a shared row names it as it likes; the foreign key holds it.
|
|
282
454
|
if (!access && !far)
|
|
@@ -312,18 +484,19 @@ export const assertReferences = async ({ kayan, store, data, tenant, access, tx
|
|
|
312
484
|
// Fail closed before the store is asked, so the foreign key cannot tell which ids exist.
|
|
313
485
|
throw refuse(sets[0].field);
|
|
314
486
|
}
|
|
315
|
-
|
|
316
|
-
const uuid = target.fields.id?.kind === 'uuid';
|
|
317
|
-
const keyOf = (value) => (uuid && typeof value === 'string' ? value.toLowerCase() : value);
|
|
487
|
+
const keyOf = keyIn(target);
|
|
318
488
|
const ids = [...new Set(sets.map(({ value }) => keyOf(value)))];
|
|
319
489
|
const found = await store.find({ kayan: target.name, args: {
|
|
320
490
|
limit: ids.length,
|
|
321
|
-
lock: '
|
|
491
|
+
// The lock a foreign key takes: it holds off the row's delete, and never another write naming the
|
|
492
|
+
// row or a plain update of its other columns, so those neither wait on it nor deadlock with it.
|
|
493
|
+
// See "Create, update and delete" in packages/mithaq/README.md for checks that need a stronger lock.
|
|
494
|
+
lock: 'keyShare',
|
|
322
495
|
page: 1,
|
|
323
496
|
sort: { field: 'id', direction: 'asc' },
|
|
324
497
|
where: combineWhere(combineWhere(combineWhere({ id: { in: ids } }, admits), tenanted),
|
|
325
498
|
// Nothing a plain read shows may point at a row no plain read shows; a step's own write may.
|
|
326
|
-
access ? publishedWhere(target) : undefined),
|
|
499
|
+
combineWhere(access ? publishedWhere(target) : undefined, key === target ? undefined : await offered(key))),
|
|
327
500
|
}, tx });
|
|
328
501
|
const admitted = new Set(found.docs.map((row) => keyOf(row.id)));
|
|
329
502
|
const refused = sets.find(({ value }) => !admitted.has(keyOf(value)));
|
|
@@ -418,17 +591,19 @@ const outsideStore = (kayan, operation, slot) => {
|
|
|
418
591
|
const refuse = () => {
|
|
419
592
|
throw new Error(`Kayan "${kayan.name}" operation "${operation}" reached the store from "${slot}", which runs outside the operation's transaction, so the write would not be in it. Write through tools.kayans, which opens its own, or move the step into "steps".`);
|
|
420
593
|
};
|
|
421
|
-
return Object.freeze({ insert: refuse, remove: refuse, update: refuse });
|
|
594
|
+
return Object.freeze({ find: refuse, hold: refuse, upsert: refuse, sum: refuse, insert: refuse, remove: refuse, update: refuse });
|
|
422
595
|
};
|
|
423
596
|
// No transaction is carried, so a managed write here opens its own. An `afterCommit`
|
|
424
597
|
// throw is never an `AsasError`: the write it would refuse has committed.
|
|
425
598
|
export const runOutside = async ({ kayan, tools, slot, list, start, around, }) => {
|
|
426
|
-
const given =
|
|
599
|
+
const given = stepTools({ ...tools, store: outsideStore(kayan, tools.operation, slot) });
|
|
600
|
+
// Read before the first step, which is handed `start` itself and may rewrite it in place.
|
|
601
|
+
const { tenant } = start;
|
|
427
602
|
return running.run(around, async () => {
|
|
428
603
|
let ctx = start;
|
|
429
604
|
try {
|
|
430
605
|
for (const step of list)
|
|
431
|
-
ctx = await step(ctx, given);
|
|
606
|
+
ctx = { ...(await step(ctx, given)), tenant };
|
|
432
607
|
}
|
|
433
608
|
catch (cause) {
|
|
434
609
|
if (slot === 'beforeTransaction')
|
|
@@ -464,7 +639,7 @@ const namesKey = (schema, key) => {
|
|
|
464
639
|
}
|
|
465
640
|
return !!answer.issues?.some(({ path: [first] = [] }) => String(typeof first === 'object' ? first.key : first) === key);
|
|
466
641
|
};
|
|
467
|
-
export const write = ({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, }) => async (data = {}, options) => {
|
|
642
|
+
export const write = ({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, pointers = [], }) => async (data = {}, options) => {
|
|
468
643
|
const list = atEffect(declaration.steps);
|
|
469
644
|
const { actor, context, entry, open } = beginWrite({ kayan, operation: tools.operation, store, cap, options });
|
|
470
645
|
// The id reaches the store before the operation's parse, so it is held to the shape at
|
|
@@ -546,7 +721,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
546
721
|
tools,
|
|
547
722
|
slot: 'beforeTransaction',
|
|
548
723
|
list: atEffect(declaration.beforeTransaction),
|
|
549
|
-
start: { kayan, data: { ...data }, actor, user: actor, tx: undefined, context },
|
|
724
|
+
start: { kayan, data: { ...data }, actor, user: actor, tx: undefined, context, tenant: owner },
|
|
550
725
|
around: { context, user: actor },
|
|
551
726
|
});
|
|
552
727
|
values = before.data;
|
|
@@ -556,7 +731,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
556
731
|
const run = (tx) => {
|
|
557
732
|
entry.tx = tx;
|
|
558
733
|
return running.run({ context, transactions: new Map(open).set(store, entry), user: actor }, async () => {
|
|
559
|
-
const existing = tools.kind === 'insert' ? undefined : await loadExisting(kayan, store, values.id, accessWhere, tx, rowsWhere);
|
|
734
|
+
const existing = tools.kind === 'insert' ? undefined : await loadExisting({ kayan, store, id: values.id, where: accessWhere, tx, rows: rowsWhere, lock: rowLock({ kayan, kind: tools.kind, data: values }) });
|
|
560
735
|
// An actor who may not read this row's drafts writes onto it as it is, so nothing tells
|
|
561
736
|
// it a draft is pending.
|
|
562
737
|
const hidesDrafts = !!versions?.drafts && !!existing && (!admits || (!draftsAsked && !!admits.where
|
|
@@ -565,7 +740,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
565
740
|
// A row found without the constraint means the caller cannot address it (404); none means
|
|
566
741
|
// the upsert. A per-tenant global is left out: the unconstrained look would find another tenant's row.
|
|
567
742
|
if (kayan.single && !kayan.tenant && tools.kind === 'update' && !existing && accessWhere
|
|
568
|
-
&& await loadExisting(kayan, store, values.id, undefined, tx)) {
|
|
743
|
+
&& await loadExisting({ kayan, store, id: values.id, where: undefined, tx, lock: 'noKeyUpdate' })) {
|
|
569
744
|
throw missing({
|
|
570
745
|
message: `No "${kayan.name}" row the access constraint admits.`,
|
|
571
746
|
key: 'error.not_found.global',
|
|
@@ -593,7 +768,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
593
768
|
: values;
|
|
594
769
|
// Carried from a draft rather than sent, so field access refuses rather than drops them.
|
|
595
770
|
const carried = merged === values ? [] : Object.keys(merged).filter((key) => !(key in values));
|
|
596
|
-
|
|
771
|
+
let pending = asIs && newest && !restoring && base?._status === 'draft' ? base : undefined;
|
|
597
772
|
// A key the operation's own shape would refuse is refused here first, in that shape's words.
|
|
598
773
|
if (drafting) {
|
|
599
774
|
const stray = Object.keys(data).find((key) => key !== 'id' && (Object.hasOwn(kayan.fields, key) ? providerOf(kayan.fields[key], fieldOperation) !== 'client' : !declaration.input || !namesKey(tools.schema, key)));
|
|
@@ -619,38 +794,83 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
619
794
|
const inputData = fieldOperation
|
|
620
795
|
? await fieldAccess(fieldOperation, kayan, given, actor, targetId, tools.operation, existing, carried)
|
|
621
796
|
: given;
|
|
622
|
-
// The audit sink writes through the same three, so only the kayan's own writes save versions
|
|
797
|
+
// The audit sink writes through the same three, so only the kayan's own writes save versions,
|
|
798
|
+
// through the store itself: a versions table is no kayan, and the seal refuses it to a step.
|
|
623
799
|
const versioned = versions ? kayan : undefined;
|
|
624
800
|
const own = (name) => versioned && name === kayan.name ? versioned : undefined;
|
|
801
|
+
// The row this write authorized, which alone takes its access, versions and soft delete, in
|
|
802
|
+
// whatever case the caller sent its uuid. A step naming any other id writes that row.
|
|
803
|
+
const keyOf = keyIn(kayan);
|
|
804
|
+
const ownRow = (name, id) => kind !== 'insert' && name === kayan.name && (keyOf(id) === keyOf(targetId) || (existing !== undefined && id === existing.id));
|
|
805
|
+
// And a raw write of this kayan never reaches the authorized row, however its id is spelled.
|
|
806
|
+
const elsewhere = (args) => kind !== 'insert' && args.kayan === kayan.name && targetId !== undefined ? { ...args, where: combineWhere(args.where, { id: { not_equals: targetId } }) } : args;
|
|
625
807
|
const protectedStore = Object.freeze({
|
|
808
|
+
find: tools.store.find,
|
|
809
|
+
hold: tools.store.hold,
|
|
810
|
+
sum: tools.store.sum,
|
|
811
|
+
upsert: async (args) => {
|
|
812
|
+
const kept = own(args.kayan);
|
|
813
|
+
if (drafting && kept)
|
|
814
|
+
throw invalid({ message: "An upsert writes stored rows, not this operation's draft snapshot. Use update for that draft.", key: 'error.invalid.value', params: { kayan: args.kayan } });
|
|
815
|
+
const row = await tools.store.upsert(args);
|
|
816
|
+
const current = ownRow(args.kayan, row.id);
|
|
817
|
+
if (kept && !(current && pending && status === 'draft'))
|
|
818
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: args.tx, row, autosave: false });
|
|
819
|
+
if (kept && current && pending) {
|
|
820
|
+
pending = { ...pending, ...Object.fromEntries(Object.keys(args.data).map((field) => [field, row[field]])), id: targetId, _status: 'draft' };
|
|
821
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: args.tx, row: pending, autosave: false });
|
|
822
|
+
}
|
|
823
|
+
return row;
|
|
824
|
+
},
|
|
626
825
|
insert: async ({ tx: heldTx, kayan: name, data: values }) => {
|
|
627
826
|
const row = await tools.store.insert({ tx: heldTx, kayan: name, data: values });
|
|
628
827
|
const kept = own(name);
|
|
629
828
|
if (kept)
|
|
630
|
-
await saveVersion({ kayan: kept, store, writes:
|
|
829
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row, autosave: autosaving });
|
|
631
830
|
return row;
|
|
632
831
|
},
|
|
633
|
-
update: async (
|
|
832
|
+
update: async (args) => {
|
|
833
|
+
const { tx: heldTx, kayan: name, id, data: values } = args;
|
|
834
|
+
// Any other row is the step's own raw write, held by the tenant seal alone.
|
|
835
|
+
if (!ownRow(name, id))
|
|
836
|
+
return tools.store.update(elsewhere(args));
|
|
634
837
|
const kept = own(name);
|
|
838
|
+
const incremented = Object.keys(values).filter((field) => kayan.fields[field]?.kind !== 'json' && values[field] && typeof values[field] === 'object' && '$inc' in values[field]);
|
|
839
|
+
if (drafting && kept && incremented.length)
|
|
840
|
+
throw invalid({ message: 'An increment writes a stored integer field, not a draft snapshot.', key: 'error.invalid.value', params: { kayan: name } });
|
|
635
841
|
if (drafting && kept) {
|
|
636
842
|
const draft = { ...base, ...values, id: targetId };
|
|
637
|
-
await saveVersion({ kayan: kept, store, writes:
|
|
843
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row: draft, autosave: autosaving });
|
|
638
844
|
return draft;
|
|
639
845
|
}
|
|
640
|
-
const row = await tools.store.update({ tx: heldTx, kayan: name, id: targetId
|
|
846
|
+
const row = await tools.store.update({ tx: heldTx, kayan: name, id: targetId, data: values, where: writeWhere });
|
|
641
847
|
// A never-published row is its own draft, so the draft appended below is its one version.
|
|
642
848
|
if (kept && !(pending && status === 'draft'))
|
|
643
|
-
await saveVersion({ kayan: kept, store, writes:
|
|
849
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row, autosave: false });
|
|
644
850
|
// Appended after the row's own, so the draft stays the latest.
|
|
645
|
-
if (kept && pending)
|
|
646
|
-
|
|
851
|
+
if (kept && pending) {
|
|
852
|
+
pending = { ...pending, ...values, ...Object.fromEntries(incremented.map((field) => [field, row[field]])), id: targetId, _status: 'draft' };
|
|
853
|
+
await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row: pending, autosave: false });
|
|
854
|
+
}
|
|
647
855
|
return row;
|
|
648
856
|
},
|
|
649
857
|
// A soft delete keeps the `remove` seal, so the effect, the access answer and the 404 stay
|
|
650
858
|
// the delete's; the mark is refused as a field any caller supplies.
|
|
651
|
-
remove: (
|
|
652
|
-
|
|
653
|
-
|
|
859
|
+
remove: async (args) => {
|
|
860
|
+
const { tx: heldTx, kayan: name, id } = args;
|
|
861
|
+
if (!ownRow(name, id))
|
|
862
|
+
return tools.store.remove(elsewhere(args));
|
|
863
|
+
if (kayan.softDelete)
|
|
864
|
+
return tools.store.update({ tx: heldTx, kayan: name, id: targetId, data: { [kayan.softDelete]: new Date() }, where: writeWhere });
|
|
865
|
+
// A marked row is kept, so only a delete that removes it answers to the rows naming it.
|
|
866
|
+
// An update step can remove its own row, so exclude new references before counting them.
|
|
867
|
+
// This raises its lock; the upgrade path is taking `update` at load for such an operation.
|
|
868
|
+
if (pointers.length && existing && kind !== 'remove')
|
|
869
|
+
await store.find({ kayan: name, args: { limit: 1, page: 1, lock: 'update', sort: byId, where: { id: { equals: existing.id } } }, tx: heldTx });
|
|
870
|
+
if (pointers.length && existing)
|
|
871
|
+
await refuseReferenced({ kayan, existing, pointers, store, tenant: kayan.tenant ? existing[kayan.tenant] : tenantOf(actor), tx: heldTx, authorize, admin, actor });
|
|
872
|
+
return tools.store.remove({ tx: heldTx, kayan: name, id: targetId, where: writeWhere });
|
|
873
|
+
},
|
|
654
874
|
});
|
|
655
875
|
// Its absence is a build fault: the partial update shape would make a row without its required fields.
|
|
656
876
|
if (kind !== tools.kind && !tools.insertSchema) {
|
|
@@ -667,13 +887,14 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
667
887
|
store: protectedStore,
|
|
668
888
|
where: writeWhere,
|
|
669
889
|
});
|
|
670
|
-
|
|
890
|
+
const publicTools = stepTools(runTools);
|
|
891
|
+
let ctx = { kayan, data: { ...inputData }, actor, user: actor, tx, context, dataBefore, dataAfter: dataBefore, tenant: owner };
|
|
671
892
|
// With no `input` the filled row is parsed, so a default reaches a required column; with one,
|
|
672
893
|
// the caller's strict shape is parsed first. A remove writes no column, so neither runs.
|
|
673
|
-
const fill = (one) => (fieldOperation ? defaults(one,
|
|
894
|
+
const fill = (one) => (fieldOperation ? defaults(one, publicTools) : one);
|
|
674
895
|
ctx = declaration.input
|
|
675
|
-
? fill(await parse({ schema: runTools.schema, ctx, tools:
|
|
676
|
-
: await parse({ schema: runTools.schema, ctx: fill(ctx), tools:
|
|
896
|
+
? fill(await parse({ schema: runTools.schema, ctx, tools: publicTools }))
|
|
897
|
+
: await parse({ schema: runTools.schema, ctx: fill(ctx), tools: publicTools });
|
|
677
898
|
// Filled after the parse, from the session alone.
|
|
678
899
|
if (kind === 'insert' && kayan.tenant)
|
|
679
900
|
ctx = { ...ctx, data: { ...ctx.data, [kayan.tenant]: owner } };
|
|
@@ -696,7 +917,11 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
696
917
|
});
|
|
697
918
|
}
|
|
698
919
|
if (kind !== 'insert' && ctx.data.id !== targetId) {
|
|
699
|
-
throw
|
|
920
|
+
throw conflict({
|
|
921
|
+
message: `Kayan "${kayan.name}" operation "${tools.operation}" cannot change the authorized target id.`,
|
|
922
|
+
key: 'error.conflict.target',
|
|
923
|
+
params: { kayan: kayan.name, operation: tools.operation },
|
|
924
|
+
});
|
|
700
925
|
}
|
|
701
926
|
if (fieldOperation) {
|
|
702
927
|
ctx = { ...ctx, data: await fieldAccess(fieldOperation, kayan, ctx.data, actor, targetId, tools.operation, existing, carried) };
|
|
@@ -735,7 +960,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
735
960
|
if (kayan.single)
|
|
736
961
|
ctx = { ...ctx, data: { ...ctx.data, globalType: kayan.name } };
|
|
737
962
|
}
|
|
738
|
-
ctx = await step(ctx,
|
|
963
|
+
ctx = { ...(await step(ctx, publicTools)), tenant: owner };
|
|
739
964
|
}
|
|
740
965
|
committed = ctx;
|
|
741
966
|
// A draft may be short of `required`, so it leaves through the draft shape, which holds it to its kinds alone.
|
|
@@ -774,6 +999,9 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
|
|
|
774
999
|
try {
|
|
775
1000
|
result = await store.transaction(run);
|
|
776
1001
|
}
|
|
1002
|
+
catch (thrown) {
|
|
1003
|
+
throw retryConflict(thrown);
|
|
1004
|
+
}
|
|
777
1005
|
finally {
|
|
778
1006
|
entry.settled = true;
|
|
779
1007
|
}
|
|
@@ -6,6 +6,20 @@ export type BetterAuthAccessOptions<Roles extends SystemRoles, Table extends Row
|
|
|
6
6
|
statements: Readonly<Record<string, readonly string[]>>;
|
|
7
7
|
}>>;
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* The access target over Better Auth's own `member.role` and nothing else: a member holds the system
|
|
11
|
+
* roles its membership row names in that organization, comma-separated, and there is no custom role
|
|
12
|
+
* and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
|
|
13
|
+
* The row is read from the store for the organization asked about, never off the session, so a
|
|
14
|
+
* membership elsewhere grants nothing here.
|
|
15
|
+
*
|
|
16
|
+
* A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
|
|
17
|
+
* is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
|
|
18
|
+
* named in it would otherwise grant its keys inside the tenant.
|
|
19
|
+
*/
|
|
20
|
+
export declare const memberRoleTarget: <const Roles extends SystemRoles, const Table extends RowTable, Actor>({ roles, rolesOf, access, }: Omit<AccessOptions<Roles, Table, Actor>, 'kayan'>) => AccessTarget<Roles, Table, Actor> & {
|
|
21
|
+
readonly type: 'memberRole';
|
|
22
|
+
};
|
|
9
23
|
/**
|
|
10
24
|
* The access target over Better Auth's store: a custom role is an `organizationRole` row.
|
|
11
25
|
* Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
|
|
@@ -29,6 +29,38 @@ const readPermission = (value) => {
|
|
|
29
29
|
}
|
|
30
30
|
return parsed;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The access target over Better Auth's own `member.role` and nothing else: a member holds the system
|
|
34
|
+
* roles its membership row names in that organization, comma-separated, and there is no custom role
|
|
35
|
+
* and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
|
|
36
|
+
* The row is read from the store for the organization asked about, never off the session, so a
|
|
37
|
+
* membership elsewhere grants nothing here.
|
|
38
|
+
*
|
|
39
|
+
* A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
|
|
40
|
+
* is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
|
|
41
|
+
* named in it would otherwise grant its keys inside the tenant.
|
|
42
|
+
*/
|
|
43
|
+
export const memberRoleTarget = ({ roles, rolesOf, access, }) => ({
|
|
44
|
+
capability: 'access',
|
|
45
|
+
type: 'memberRole',
|
|
46
|
+
roles,
|
|
47
|
+
...(rolesOf ? { rolesOf } : {}),
|
|
48
|
+
access: (access ?? {}),
|
|
49
|
+
// Better Auth's own model and columns.
|
|
50
|
+
reads: ['member'],
|
|
51
|
+
resolve: async ({ store, tenant, member, actor }) => {
|
|
52
|
+
// A read without a tenant would be every tenant's; the actor still goes through `rolesOf`, which
|
|
53
|
+
// answers backoffice staff, who have no active organization, their backoffice roles.
|
|
54
|
+
const found = scoped(tenant) && scoped(member)
|
|
55
|
+
? await allRows({ store, kayan: 'member', where: { and: [{ organizationId: { equals: tenant } }, { userId: { equals: member } }] } })
|
|
56
|
+
: { docs: [] };
|
|
57
|
+
// Split the way the provider's own owner checks split it: exact, untrimmed, so ` owner` is no owner.
|
|
58
|
+
const slugs = [...new Set(found.docs.flatMap((row) => (typeof row.role === 'string' ? row.role.split(',').filter(Boolean) : [])))];
|
|
59
|
+
const held = heldSlugs({ roles, rolesOf, slugs, actor });
|
|
60
|
+
const { everything, keys } = systemKeys(roles, held);
|
|
61
|
+
return { permissions: everything ? '*' : [...keys], roles: held };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
32
64
|
/**
|
|
33
65
|
* The access target over Better Auth's store: a custom role is an `organizationRole` row.
|
|
34
66
|
* Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
|
|
@@ -8,9 +8,11 @@ export declare const header = "// Written by `mithaq generate auth` from the Bet
|
|
|
8
8
|
* Writes one kayan file per Better Auth table, and one barrel beside them. With `check`, writes
|
|
9
9
|
* nothing and returns the files a run would change or delete. A file without the header is never touched.
|
|
10
10
|
*/
|
|
11
|
-
export declare const generateAuth: ({ options, directory, check, provider, providers, screens, schemaName, }: {
|
|
11
|
+
export declare const generateAuth: ({ options, directory, language, check, provider, providers, screens, schemaName, }: {
|
|
12
12
|
options: Parameters<typeof getAuthTables>[0];
|
|
13
13
|
directory: URL;
|
|
14
|
+
/** The module each generated kayan imports `defineKayan` from: `generate.language`, as given. */
|
|
15
|
+
language: string;
|
|
14
16
|
check?: boolean;
|
|
15
17
|
/** The Postgres schema every kayan of this instance is built in, from its `providers` entry. */
|
|
16
18
|
schemaName?: string;
|