@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.
- package/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/README.md +160 -2
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +239 -0
- package/dist/asas/build.js +834 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +177 -0
- package/dist/asas/cli/upgrade.d.ts +33 -0
- package/dist/asas/cli/upgrade.js +256 -0
- package/dist/asas/http/fetch.d.ts +41 -0
- package/dist/asas/http/fetch.js +178 -0
- package/dist/asas/http/orpc.d.ts +45 -0
- package/dist/asas/http/orpc.js +153 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +196 -0
- package/dist/asas/runner/errors.d.ts +63 -0
- package/dist/asas/runner/errors.js +75 -0
- package/dist/asas/runner/read.d.ts +72 -0
- package/dist/asas/runner/read.js +601 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +418 -0
- package/dist/asas/runner/steps.js +277 -0
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +275 -0
- package/dist/asas/runner/write.d.ts +606 -0
- package/dist/asas/runner/write.js +1019 -0
- package/dist/auth/better-auth/access.d.ts +29 -0
- package/dist/auth/better-auth/access.js +103 -0
- package/dist/auth/better-auth/generate.d.ts +31 -0
- package/dist/auth/better-auth/generate.js +328 -0
- package/dist/auth/better-auth/index.d.ts +94 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +26 -0
- package/dist/auth/better-auth/tenant.js +34 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +738 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +124 -0
- package/dist/base/fields.js +52 -0
- package/dist/base/index.d.ts +47 -0
- package/dist/base/index.js +5 -0
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +75 -0
- package/dist/base/kinds.js +123 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +81 -0
- package/dist/drizzle/index.d.ts +308 -0
- package/dist/drizzle/index.js +951 -0
- package/dist/drizzle/kinds.d.ts +28 -0
- package/dist/drizzle/kinds.js +43 -0
- package/dist/drizzle/migrate.d.ts +159 -0
- package/dist/drizzle/migrate.js +1328 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +26 -0
- package/dist/mithaq/define.d.ts +335 -0
- package/dist/mithaq/define.js +57 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +235 -0
- package/dist/mithaq/language.d.ts +374 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +111 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +1014 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +386 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +28 -0
- package/dist/rich-text/index.js +59 -0
- package/dist/rich-text/rich-text.d.ts +106 -0
- package/dist/rich-text/rich-text.js +249 -0
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +156 -0
- package/dist/screen/client.js +71 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +268 -0
- package/dist/screen/index.d.ts +496 -0
- package/dist/screen/index.js +663 -0
- package/dist/screen/react.d.ts +97 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +42 -0
- package/dist/storage/index.js +43 -0
- package/dist/storage/storage.d.ts +386 -0
- package/dist/storage/storage.js +412 -0
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +193 -0
- package/dist/zod/index.js +122 -0
- package/dist/zod/kinds.d.ts +36 -0
- package/dist/zod/kinds.js +58 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- 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 +92 -4
- 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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { isRef } from '../../mithaq/user.js';
|
|
2
|
+
import { declaredFieldAccess } from '../../mithaq/rows.js';
|
|
3
|
+
import { providerOf } from '../../base/rules.js';
|
|
4
|
+
import { invalid } from './errors.js';
|
|
5
|
+
import { snapshot } from './runtime.js';
|
|
6
|
+
import { rawStepStore } from './store.js';
|
|
7
|
+
// The assertion checks the list stays inside `OperationStep<never>`, not the direction it names.
|
|
8
|
+
export const atEffect = (list) => list;
|
|
9
|
+
// A map only this module writes: a tag on the function would let any function buy
|
|
10
|
+
// the position that skips parsing.
|
|
11
|
+
const seals = new WeakMap();
|
|
12
|
+
// The derived shapes exist only after `resolve`, so the function waits for `build`.
|
|
13
|
+
const outputs = new WeakMap();
|
|
14
|
+
/** A step is compared by the seal it carries, so a validate function's step is one too. */
|
|
15
|
+
const seal = (name, step) => {
|
|
16
|
+
seals.set(step, name);
|
|
17
|
+
return step;
|
|
18
|
+
};
|
|
19
|
+
/** What position this step holds, or nothing at all for a step of the app's own. */
|
|
20
|
+
export const sealOf = (step) => seals.get(step);
|
|
21
|
+
// Each copy of mithaq in the process lists its seals here, so a step a second install sealed is
|
|
22
|
+
// named rather than miscounted. Anything can write this map, so its answer only ever refuses.
|
|
23
|
+
const copies = (globalThis[Symbol.for('mithaq.copies')] ??= new Map());
|
|
24
|
+
copies.set(import.meta.url, (step) => seals.get(step));
|
|
25
|
+
/** Where the other copy of mithaq that sealed this step was loaded from, if one did. */
|
|
26
|
+
export const foreignCopyOf = (step) => [...copies].find(([url, of]) => url !== import.meta.url && of(step))?.[0];
|
|
27
|
+
/** Hold an operation's `output` function until `build` has the derived shapes. */
|
|
28
|
+
export const holdOutput = (operation, of) => {
|
|
29
|
+
outputs.set(operation, of);
|
|
30
|
+
};
|
|
31
|
+
/** The schema function this operation's `output` carries, or nothing for a bare schema. */
|
|
32
|
+
export const outputOf = (operation) => outputs.get(operation);
|
|
33
|
+
/** The list's store step, the one that makes, changes or removes the row. */
|
|
34
|
+
export const storeSeals = ['insert', 'remove', 'update'];
|
|
35
|
+
export const readSeals = ['count', 'find', 'findOne'];
|
|
36
|
+
// Apart from `storeSeals`: `write` reads that list to place its single-row guards, which
|
|
37
|
+
// a bulk list never reaches.
|
|
38
|
+
export const bulkSeals = ['deleteMany', 'updateMany'];
|
|
39
|
+
// Each its own acting step, so each is its own permission key.
|
|
40
|
+
export const versionSeals = ['findVersions', 'findVersionByID', 'restoreVersion'];
|
|
41
|
+
/** The read no actor holds, which a secret column carries. One function, so a column is known by it. */
|
|
42
|
+
export const neverRead = () => false;
|
|
43
|
+
// An audit row outlives the column and is read by whoever reads the log, so no secret goes in it.
|
|
44
|
+
const withoutSecrets = (kayan, data) => data && typeof data === 'object'
|
|
45
|
+
? Object.freeze(Object.fromEntries(Object.entries(data).filter(([name]) => declaredFieldAccess(kayan.fields[name]?.access?.read) !== neverRead)))
|
|
46
|
+
: data;
|
|
47
|
+
/**
|
|
48
|
+
* The audit step a project lists in the service target's `afterSteps`. A step after the store
|
|
49
|
+
* that changes persistence must return the persisted row in `ctx.data`. Listing it without a sink fails the build.
|
|
50
|
+
*/
|
|
51
|
+
export const audit = seal('audit', async (ctx, tools) => {
|
|
52
|
+
if (!tools)
|
|
53
|
+
return ctx;
|
|
54
|
+
await tools.audit?.({
|
|
55
|
+
context: ctx.context,
|
|
56
|
+
effect: tools.effect,
|
|
57
|
+
kayan: ctx.kayan,
|
|
58
|
+
operation: tools.operation,
|
|
59
|
+
dataBefore: withoutSecrets(ctx.kayan, ctx.dataBefore),
|
|
60
|
+
dataAfter: withoutSecrets(ctx.kayan, ctx.dataAfter ?? snapshot(ctx.data) ?? ctx.data),
|
|
61
|
+
store: 'store' in tools ? rawStepStore(tools.store) : undefined,
|
|
62
|
+
tx: ctx.tx,
|
|
63
|
+
user: ctx.user,
|
|
64
|
+
});
|
|
65
|
+
return ctx;
|
|
66
|
+
});
|
|
67
|
+
// A row's filter and a default read the same reference off the same actor, so they agree.
|
|
68
|
+
const defaultOf = (field, ctx, tools) => {
|
|
69
|
+
const value = field.rules?.defaultValue;
|
|
70
|
+
// Copied whole, so a step changing one row's list or object never changes the next row's default.
|
|
71
|
+
if (!isRef(value))
|
|
72
|
+
return typeof value === 'object' && value !== null ? structuredClone(value) : value;
|
|
73
|
+
if (!ctx.user) {
|
|
74
|
+
throw new Error(`Kayan "${ctx.kayan.name}" operation "${tools.operation}" field "${field.name}" defaults to "user.${value.$user}", and this write was given no actor. Pass one: create(data, { actor }).`);
|
|
75
|
+
}
|
|
76
|
+
// An own key only: a JSON reference could name `__proto__`.
|
|
77
|
+
const actor = ctx.user;
|
|
78
|
+
const found = Object.hasOwn(actor, value.$user) ? actor[value.$user] : undefined;
|
|
79
|
+
// A key the actor does not carry would otherwise be written as null while the row filter
|
|
80
|
+
// naming it refuses to run.
|
|
81
|
+
if (found === null || found === undefined) {
|
|
82
|
+
throw new Error(`Kayan "${ctx.kayan.name}" operation "${tools.operation}" field "${field.name}" defaults to "user.${value.$user}", and the actor carries nothing there.`);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Fills what the write left out. On a list that only changes a row only `nowOnUpdate` fields
|
|
88
|
+
* are filled, or `createdAt` would take the edit's moment. The runner runs it; a list naming it is refused.
|
|
89
|
+
*/
|
|
90
|
+
export const defaults = seal('defaults', (ctx, tools) => {
|
|
91
|
+
const data = { ...ctx.data };
|
|
92
|
+
const insert = tools.kind === 'insert';
|
|
93
|
+
for (const field of Object.values(ctx.kayan.fields)) {
|
|
94
|
+
if (data[field.name] !== undefined)
|
|
95
|
+
continue;
|
|
96
|
+
if (field.rules?.[insert ? 'defaultNow' : 'nowOnUpdate'] === true)
|
|
97
|
+
data[field.name] = field.now();
|
|
98
|
+
else if (insert) {
|
|
99
|
+
const value = defaultOf(field, ctx, tools);
|
|
100
|
+
if (value !== undefined)
|
|
101
|
+
data[field.name] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { ...ctx, data };
|
|
105
|
+
});
|
|
106
|
+
// The refusal's params carry each path and rule, so a client need not parse the English message.
|
|
107
|
+
const issuesOf = (issues) => issues.map((issue) => ({
|
|
108
|
+
path: (issue.path ?? [])
|
|
109
|
+
.map((segment) => String(typeof segment === 'object' ? segment.key : segment))
|
|
110
|
+
.join('.'),
|
|
111
|
+
message: issue.message,
|
|
112
|
+
// What a schema said beside its message, such as the node type a rich
|
|
113
|
+
// text value holds that its editor does not.
|
|
114
|
+
...(issue.params ? { params: issue.params } : {}),
|
|
115
|
+
}));
|
|
116
|
+
/** Every issue as one message, each prefixed by the path it happened at. */
|
|
117
|
+
const issueMessage = (issues) => issuesOf(issues)
|
|
118
|
+
.map(({ message, path }) => (path ? `${path}: ${message}` : message))
|
|
119
|
+
.join('; ');
|
|
120
|
+
/** The refusal a list of Standard Schema issues becomes, shared by the parse and the oRPC boundary so every 400 has one shape. */
|
|
121
|
+
export const issueRefusal = (issues, vendor = 'unknown') => invalid({
|
|
122
|
+
message: issueMessage(issues) || `The "${vendor}" schema refused the value and reported no issues.`,
|
|
123
|
+
key: 'error.invalid.value',
|
|
124
|
+
params: { issues: issuesOf(issues) },
|
|
125
|
+
});
|
|
126
|
+
/** A plain object copied without a prototype, because zod reads an unsent key off the prototype. */
|
|
127
|
+
export const withoutPrototype = (value) => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype
|
|
128
|
+
? Object.assign(Object.create(null), value)
|
|
129
|
+
: value;
|
|
130
|
+
/** Validates through Standard Schema's own `validate`, sync or async, never one library's `parse`. */
|
|
131
|
+
export const validated = async (schema, value) => {
|
|
132
|
+
const result = await schema['~standard'].validate(withoutPrototype(value));
|
|
133
|
+
if (result.issues)
|
|
134
|
+
throw issueRefusal(result.issues, schema['~standard'].vendor);
|
|
135
|
+
return result.value;
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* The runner's parse. A field the client does not supply is absent from the wire shape, so a value the
|
|
139
|
+
* runner put there is set aside, the rest parsed, and the set-aside values put back. `id` is never set aside.
|
|
140
|
+
*/
|
|
141
|
+
export const parse = async ({ schema, ctx, tools }) => {
|
|
142
|
+
const on = tools.kind === 'insert' ? 'create' : tools.kind === 'update' ? 'update' : undefined;
|
|
143
|
+
if (!on)
|
|
144
|
+
return { ...ctx, data: (await validated(schema, ctx.data)) };
|
|
145
|
+
const sent = tools.sent ?? [];
|
|
146
|
+
const server = Object.create(null);
|
|
147
|
+
const client = Object.create(null);
|
|
148
|
+
for (const [name, value] of Object.entries(ctx.data)) {
|
|
149
|
+
const field = Object.hasOwn(ctx.kayan.fields, name) ? ctx.kayan.fields[name] : undefined;
|
|
150
|
+
const mine = !!field && name !== 'id' && !sent.includes(name) && providerOf(field, on) !== 'client';
|
|
151
|
+
(mine ? server : client)[name] = value;
|
|
152
|
+
}
|
|
153
|
+
return { ...ctx, data: { ...(await validated(schema, client)), ...server } };
|
|
154
|
+
};
|
|
155
|
+
/** A marker, refused by name in a list: the parse is the runner's own. The shape it carried is the operation's `input`. */
|
|
156
|
+
export const validate = seal('validate', (() => validate));
|
|
157
|
+
// The derived shapes are strict, so no undeclared key reaches the store.
|
|
158
|
+
const columns = (ctx) => Object.fromEntries(Object.entries(ctx.data).filter(([name]) => name in ctx.kayan.fields));
|
|
159
|
+
/** Make the row, and what came back is what the rest of the list sees. */
|
|
160
|
+
export const insert = seal('insert', async (ctx, tools) => {
|
|
161
|
+
const row = await tools.store.insert({ tx: ctx.tx, kayan: ctx.kayan, data: columns(ctx) });
|
|
162
|
+
return { ...ctx, data: { ...ctx.data, ...row }, dataAfter: snapshot(row) };
|
|
163
|
+
});
|
|
164
|
+
/** Change the one row the id names; everything else in `data` is the change. An id alone is refused with a 400. */
|
|
165
|
+
export const update = seal('update', async (ctx, tools) => {
|
|
166
|
+
// The runner sets `kind` to `insert` for a global's first update, so `defaults` fills create-time defaults too.
|
|
167
|
+
if (tools.kind === 'insert')
|
|
168
|
+
return insert(ctx, tools);
|
|
169
|
+
const values = columns(ctx);
|
|
170
|
+
delete values.id;
|
|
171
|
+
if (!Object.keys(values).length) {
|
|
172
|
+
throw invalid({
|
|
173
|
+
message: `Kayan "${ctx.kayan.name}" operation "${tools.operation}" reached the update step with nothing but an id, so there is nothing to change.`,
|
|
174
|
+
key: 'error.invalid.empty_update',
|
|
175
|
+
params: { kayan: ctx.kayan.name, operation: tools.operation },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const row = await tools.store.update({ tx: ctx.tx, kayan: ctx.kayan, id: ctx.data.id, data: values, where: tools.where });
|
|
179
|
+
return { ...ctx, data: { ...ctx.data, ...row }, dataAfter: snapshot(row) };
|
|
180
|
+
});
|
|
181
|
+
/** Remove the one row the id names, and hand back what was removed. */
|
|
182
|
+
export const remove = seal('remove', async (ctx, tools) => {
|
|
183
|
+
const data = await tools.store.remove({ tx: ctx.tx, kayan: ctx.kayan, id: ctx.data.id, where: tools.where });
|
|
184
|
+
return { ...ctx, data, dataAfter: snapshot(data) };
|
|
185
|
+
});
|
|
186
|
+
// The seal and nothing else: `bulk` supplies `tools.many`.
|
|
187
|
+
const manyStep = async (ctx, tools) => {
|
|
188
|
+
if (!tools.many) {
|
|
189
|
+
throw new Error(`Kayan "${ctx.kayan.name}" operation "${tools.operation}" has a bulk step and no bulk runner, so nothing would be written.`);
|
|
190
|
+
}
|
|
191
|
+
return { ...ctx, data: (await tools.many(ctx.data)) };
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Change every row the `where` matches. A wrapper of its own, because a seal is keyed on
|
|
195
|
+
* the step object and two seals on one object would answer for both.
|
|
196
|
+
*/
|
|
197
|
+
export const updateMany = seal('updateMany', (ctx, tools) => manyStep(ctx, tools));
|
|
198
|
+
/** Remove every row the `where` matches, or mark it where the language names a soft delete. */
|
|
199
|
+
export const deleteMany = seal('deleteMany', (ctx, tools) => manyStep(ctx, tools));
|
|
200
|
+
const managedRead = async (ctx, tools) => {
|
|
201
|
+
const data = await tools.read(ctx.data);
|
|
202
|
+
return { ...ctx, data, dataAfter: snapshot(data) };
|
|
203
|
+
};
|
|
204
|
+
/** One row, the way `Store.find` with a limit of one answers for it. */
|
|
205
|
+
export const findOne = seal('findOne', (ctx, tools) => managedRead(ctx, tools));
|
|
206
|
+
/** A paginated page of rows. */
|
|
207
|
+
export const find = seal('find', (ctx, tools) => managedRead(ctx, tools));
|
|
208
|
+
/** How many rows the `where` matches, as `{ totalDocs }`, through the store's own `count` where it has one. */
|
|
209
|
+
export const count = seal('count', (ctx, tools) => managedRead(ctx, tools));
|
|
210
|
+
/** A page of one kayan's versions, read through the row's own read access. */
|
|
211
|
+
export const findVersions = seal('findVersions', (ctx, tools) => managedRead(ctx, tools));
|
|
212
|
+
/** One version by its own id. */
|
|
213
|
+
export const findVersionByID = seal('findVersionByID', (ctx, tools) => managedRead(ctx, tools));
|
|
214
|
+
/** Write one version back to its row; the runner turns it into the row's own update before anything is asked. */
|
|
215
|
+
export const restoreVersion = seal('restoreVersion', (ctx, tools) => update(ctx, tools));
|
|
216
|
+
const sealedSteps = { audit, count, defaults, deleteMany, find, findOne, findVersionByID, findVersions, insert, remove, restoreVersion, update, updateMany, validate };
|
|
217
|
+
/** The shipped lists a kayan spreads into its own `targets.service.operations`: `operations: { ...defaultOperations, archive }`. */
|
|
218
|
+
export const defaultOperations = {
|
|
219
|
+
create: { steps: [insert] },
|
|
220
|
+
update: { steps: [update] },
|
|
221
|
+
delete: { steps: [remove] },
|
|
222
|
+
find: { steps: [find] },
|
|
223
|
+
findByID: { steps: [findOne] },
|
|
224
|
+
// A list like any other, so a kayan overrides it or leaves it out.
|
|
225
|
+
count: { steps: [count] },
|
|
226
|
+
// No default is filled for a bulk: each row's single-row write fills its own.
|
|
227
|
+
updateMany: { steps: [updateMany] },
|
|
228
|
+
deleteMany: { steps: [deleteMany] },
|
|
229
|
+
};
|
|
230
|
+
// A global is found by being the only row, so its read takes a depth alone.
|
|
231
|
+
const globalRead = {
|
|
232
|
+
'~standard': {
|
|
233
|
+
version: 1,
|
|
234
|
+
vendor: 'mithaq',
|
|
235
|
+
validate: (value) => {
|
|
236
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
237
|
+
return { issues: [{ message: 'Invalid input: expected object' }] };
|
|
238
|
+
const stray = Object.keys(value).filter((key) => key !== 'depth');
|
|
239
|
+
if (stray.length)
|
|
240
|
+
return { issues: stray.map((key) => ({ message: `Unrecognized key: "${key}"` })) };
|
|
241
|
+
return { value: 'depth' in value ? { depth: value.depth } : {} };
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
/** The two lists a global spreads into its own `targets.service.operations`: `findOne` and `update`. */
|
|
246
|
+
export const defaultGlobalOperations = {
|
|
247
|
+
// `update` first, the order a global's two are published in.
|
|
248
|
+
update: { steps: [update] },
|
|
249
|
+
findOne: { input: () => globalRead, steps: [findOne] },
|
|
250
|
+
};
|
|
251
|
+
/** The shipped lists by name, which is what a steps function is handed as `operations`. */
|
|
252
|
+
const defaultLists = Object.fromEntries(Object.entries(defaultOperations).map(([name, { steps }]) => [name, steps]));
|
|
253
|
+
/** The four keys an operation writes a list under, which is where a function may sit. */
|
|
254
|
+
const listKeys = ['steps', 'beforeTransaction', 'afterSteps', 'afterCommit'];
|
|
255
|
+
// Every steps function runs once, here, so what `resolve` seals is data.
|
|
256
|
+
export const resolveOperations = (declared, slots = {}) => {
|
|
257
|
+
const given = {
|
|
258
|
+
operations: defaultLists,
|
|
259
|
+
steps: sealedSteps,
|
|
260
|
+
beforeSteps: slots.beforeSteps ?? [],
|
|
261
|
+
afterSteps: slots.afterSteps ?? [],
|
|
262
|
+
afterCommit: slots.afterCommit ?? [],
|
|
263
|
+
};
|
|
264
|
+
if (!declared)
|
|
265
|
+
return {};
|
|
266
|
+
const resolved = Object.create(null);
|
|
267
|
+
for (const [name, declaration] of Object.entries(declared)) {
|
|
268
|
+
if (declaration === undefined)
|
|
269
|
+
continue;
|
|
270
|
+
const held = declaration;
|
|
271
|
+
const ran = Object.fromEntries(listKeys
|
|
272
|
+
.filter((key) => typeof held[key] === 'function')
|
|
273
|
+
.map((key) => [key, held[key](given)]));
|
|
274
|
+
resolved[name] = Object.keys(ran).length ? { ...declaration, ...ran } : declaration;
|
|
275
|
+
}
|
|
276
|
+
return resolved;
|
|
277
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RawStepStore, StepStore } from '../../mithaq/language.ts';
|
|
2
|
+
import type { RuntimeWriteTools, WriteTools } from './steps.ts';
|
|
3
|
+
export declare const rawStepStore: (store: StepStore) => RawStepStore;
|
|
4
|
+
export declare const stepTools: (tools: RuntimeWriteTools) => WriteTools;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { invalid } from './errors.js';
|
|
2
|
+
// Audit sinks still implement the adapter contract; no raw handle is a property a step can reach.
|
|
3
|
+
const raw = new WeakMap();
|
|
4
|
+
const declaredStore = (store) => {
|
|
5
|
+
const wrapped = Object.freeze(Object.fromEntries(Object.entries(store).map(([word, call]) => [word, (args) => {
|
|
6
|
+
const kayan = args.kayan;
|
|
7
|
+
if (!kayan || typeof kayan !== 'object' || typeof kayan.name !== 'string' || !kayan.fields || typeof kayan.fields !== 'object') {
|
|
8
|
+
throw invalid({ message: 'A step store call names its kayan by its declaration, not a string.', key: 'error.invalid.value' });
|
|
9
|
+
}
|
|
10
|
+
return call({ ...args, kayan: kayan.name });
|
|
11
|
+
}])));
|
|
12
|
+
raw.set(wrapped, store);
|
|
13
|
+
return wrapped;
|
|
14
|
+
};
|
|
15
|
+
export const rawStepStore = (store) => {
|
|
16
|
+
const held = raw.get(store);
|
|
17
|
+
if (!held)
|
|
18
|
+
throw new Error('The audit step needs the store handed to it by the runner.');
|
|
19
|
+
return held;
|
|
20
|
+
};
|
|
21
|
+
export const stepTools = (tools) => Object.freeze({ ...tools, store: declaredStore(tools.store) });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AssignedResolver } from '../actor.ts';
|
|
2
|
+
import { type Language, type Store } from '../../mithaq/language.ts';
|
|
3
|
+
import type { ResolvedKayan } from '../../mithaq/types.ts';
|
|
4
|
+
import { type Where } from '../../mithaq/where.ts';
|
|
5
|
+
import type { OperationEffect, RuntimeWriteTools as WriteTools } from './steps.ts';
|
|
6
|
+
import { type Authorize } from './write.ts';
|
|
7
|
+
/** How far a read reaches past the active organization: every verified membership, or every tenant. */
|
|
8
|
+
export type TenantReach = 'mine' | 'all';
|
|
9
|
+
/** A read's reach, opened once by the outermost read and kept by every managed call under it. */
|
|
10
|
+
export type TenantScope = {
|
|
11
|
+
reach: 'all';
|
|
12
|
+
} | {
|
|
13
|
+
reach: 'mine';
|
|
14
|
+
members: ReadonlyMap<string, object>;
|
|
15
|
+
};
|
|
16
|
+
/** The members of a `'mine'` scope whose organization `where` admitted, or nothing for any other answer. */
|
|
17
|
+
export declare const membersAdmitted: (scope: TenantScope | undefined, where: Where | undefined) => object[] | undefined;
|
|
18
|
+
/** Refused before any step runs: a write lands in the active organization alone. */
|
|
19
|
+
export declare const refuseTenantOption: ({ kayan, operation, options }: {
|
|
20
|
+
kayan: ResolvedKayan;
|
|
21
|
+
operation: string;
|
|
22
|
+
options: unknown;
|
|
23
|
+
}) => void;
|
|
24
|
+
/**
|
|
25
|
+
* The organization a write to a tenant-owned kayan lands in, refused before any step runs where
|
|
26
|
+
* there is none, and a caller's own value for the field refused as the derived shapes refuse it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const writeTenant: ({ kayan, active, effect, data }: {
|
|
29
|
+
kayan: ResolvedKayan;
|
|
30
|
+
active: string | undefined;
|
|
31
|
+
effect: OperationEffect;
|
|
32
|
+
data: unknown;
|
|
33
|
+
}) => string | undefined;
|
|
34
|
+
/** The build's tenant scope: `open` reads a read's `tenant` option, `scoped` puts the scope under every answer. */
|
|
35
|
+
export declare const tenancy: ({ language, assigned, onError, collection }: {
|
|
36
|
+
language: Language;
|
|
37
|
+
assigned: AssignedResolver | undefined;
|
|
38
|
+
onError: (error: unknown) => void;
|
|
39
|
+
/** The build's one Better Auth kayan, whose sessions alone name a tenant. */
|
|
40
|
+
collection: string | undefined;
|
|
41
|
+
}) => {
|
|
42
|
+
open: ({ actor, reach, kayan }: {
|
|
43
|
+
actor: unknown;
|
|
44
|
+
reach: unknown;
|
|
45
|
+
kayan: ResolvedKayan;
|
|
46
|
+
}) => Promise<TenantScope | undefined>;
|
|
47
|
+
scoped: (inner: Authorize) => Authorize;
|
|
48
|
+
active: (actor: unknown) => string | undefined;
|
|
49
|
+
sealWrites: ({ writes, find, kayanOf }: {
|
|
50
|
+
writes: Omit<WriteTools['store'], 'hold'>;
|
|
51
|
+
find: Store['find'];
|
|
52
|
+
kayanOf: (name: string) => ResolvedKayan | undefined;
|
|
53
|
+
}) => WriteTools['store'];
|
|
54
|
+
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The tenant scope is core's and never a project's: it is ANDed onto whatever the project's
|
|
2
|
+
// access answers, so no row table, callback or authorizer can widen it.
|
|
3
|
+
import { authActor, markAuthActor, resolveAssigned } from '../actor.js';
|
|
4
|
+
import { isTenant } from '../../mithaq/language.js';
|
|
5
|
+
import { combineWhere, compareValues, same } from '../../mithaq/where.js';
|
|
6
|
+
import { AsasError, denied, invalid, missing } from './errors.js';
|
|
7
|
+
import { running } from './runtime.js';
|
|
8
|
+
import { assertStoreWhere, assertReferences } from './write.js';
|
|
9
|
+
// The session must come from the one Better Auth kayan of the build, whose organizations the
|
|
10
|
+
// tenant kayan holds: another instance's organization ids name no tenant here.
|
|
11
|
+
const sessionOf = ({ actor, collection }) => {
|
|
12
|
+
const trusted = authActor(actor);
|
|
13
|
+
return collection !== undefined && trusted?.strategy === 'better-auth' && trusted.collection === collection ? trusted : null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The organization the actor's verified Better Auth session has active, where the provider
|
|
17
|
+
* lists the actor as its member; nothing for any other actor, which a tenant-owned kayan refuses.
|
|
18
|
+
*/
|
|
19
|
+
const activeTenant = ({ actor, collection }) => {
|
|
20
|
+
const trusted = sessionOf({ actor, collection });
|
|
21
|
+
if (!trusted)
|
|
22
|
+
return undefined;
|
|
23
|
+
const active = trusted.activeOrganizationId;
|
|
24
|
+
if (typeof active !== 'string' || !active || !Array.isArray(trusted.organizations))
|
|
25
|
+
return undefined;
|
|
26
|
+
return trusted.organizations.some((one) => one.id === active) ? active : undefined;
|
|
27
|
+
};
|
|
28
|
+
// The organizations a `'mine'` answer admitted, kept by the answer itself so a read masks and
|
|
29
|
+
// judges each row by the membership it came through.
|
|
30
|
+
const admitted = new WeakMap();
|
|
31
|
+
/** The members of a `'mine'` scope whose organization `where` admitted, or nothing for any other answer. */
|
|
32
|
+
export const membersAdmitted = (scope, where) => {
|
|
33
|
+
if (scope?.reach !== 'mine' || !where)
|
|
34
|
+
return undefined;
|
|
35
|
+
const orgs = admitted.get(where);
|
|
36
|
+
return orgs?.map((org) => scope.members.get(org));
|
|
37
|
+
};
|
|
38
|
+
/** Refused before any step runs: a write lands in the active organization alone. */
|
|
39
|
+
export const refuseTenantOption = ({ kayan, operation, options }) => {
|
|
40
|
+
if (!options || typeof options !== 'object' || options.tenant === undefined)
|
|
41
|
+
return;
|
|
42
|
+
throw invalid({
|
|
43
|
+
message: `Kayan "${kayan.name}" operation "${operation}" writes in the active organization alone, so it takes no "tenant" option.`,
|
|
44
|
+
key: 'error.invalid.tenant_option',
|
|
45
|
+
params: { kayan: kayan.name, operation },
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The organization a write to a tenant-owned kayan lands in, refused before any step runs where
|
|
50
|
+
* there is none, and a caller's own value for the field refused as the derived shapes refuse it.
|
|
51
|
+
*/
|
|
52
|
+
export const writeTenant = ({ kayan, active, effect, data }) => {
|
|
53
|
+
if (!kayan.tenant)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (data && typeof data === 'object' && Object.hasOwn(data, kayan.tenant)) {
|
|
56
|
+
throw invalid({
|
|
57
|
+
message: `Unrecognized key: "${kayan.tenant}"`,
|
|
58
|
+
key: 'error.invalid.value',
|
|
59
|
+
params: { issues: [{ path: kayan.tenant, message: `Unrecognized key: "${kayan.tenant}"` }] },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (active === undefined)
|
|
63
|
+
throw noScope(kayan, effect);
|
|
64
|
+
return active;
|
|
65
|
+
};
|
|
66
|
+
const noScope = (kayan, effect) => denied({
|
|
67
|
+
message: `Forbidden: "${kayan.name}" rows belong to an organization, and this actor's session has no active organization it is a verified member of.`,
|
|
68
|
+
key: effect === 'read' ? 'error.forbidden.read' : effect === 'create' ? 'error.forbidden.create' : 'error.forbidden.operation',
|
|
69
|
+
params: { kayan: kayan.name, operation: effect },
|
|
70
|
+
});
|
|
71
|
+
/** The build's tenant scope: `open` reads a read's `tenant` option, `scoped` puts the scope under every answer. */
|
|
72
|
+
export const tenancy = ({ language, assigned, onError, collection }) => {
|
|
73
|
+
const target = language.targets.tenant;
|
|
74
|
+
const active = (actor) => activeTenant({ actor, collection });
|
|
75
|
+
const refused = (message) => denied({ message: `Forbidden: ${message}`, key: 'error.forbidden.read', params: {} });
|
|
76
|
+
const open = async ({ actor, reach, kayan }) => {
|
|
77
|
+
if (reach === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (reach !== 'mine' && reach !== 'all') {
|
|
80
|
+
throw invalid({ message: 'Managed read option "tenant" must be "mine" or "all".', key: 'error.invalid.tenant_option', params: { kayan: kayan.name } });
|
|
81
|
+
}
|
|
82
|
+
// A global is one row per tenant, so a read of one names its tenant.
|
|
83
|
+
if (!isTenant(target) || kayan.single) {
|
|
84
|
+
throw invalid({
|
|
85
|
+
message: `Kayan "${kayan.name}" ${kayan.single ? 'is a global, whose read is its active organization\'s row' : 'is read in a build that lays down no tenant target'}, so it takes no "tenant" option.`,
|
|
86
|
+
key: 'error.invalid.tenant_option',
|
|
87
|
+
params: { kayan: kayan.name },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (reach === 'all') {
|
|
91
|
+
const trusted = authActor(actor);
|
|
92
|
+
if (!trusted || !target.backoffice || (await target.backoffice({ user: actor })) !== true) {
|
|
93
|
+
throw refused('tenant "all" reads every organization, which only backoffice staff in the backoffice app may.');
|
|
94
|
+
}
|
|
95
|
+
return { reach };
|
|
96
|
+
}
|
|
97
|
+
// Without an access target the actor's roles are one set for every organization, and
|
|
98
|
+
// carrying the active one's into another would widen it.
|
|
99
|
+
const trusted = sessionOf({ actor, collection });
|
|
100
|
+
if (!assigned || !trusted || !Array.isArray(trusted.organizations) || trusted.id === undefined || trusted.id === null) {
|
|
101
|
+
throw refused('tenant "mine" reads the organizations a verified Better Auth session is a member of, each under the roles the access target holds there.');
|
|
102
|
+
}
|
|
103
|
+
const ids = [...new Set(trusted.organizations.map((one) => one.id).filter((id) => typeof id === 'string' && id !== ''))];
|
|
104
|
+
const members = new Map();
|
|
105
|
+
for (const org of ids) {
|
|
106
|
+
// The trusted snapshot's facts, never the actor object's own keys. The provider's
|
|
107
|
+
// governance role is the active organization's, so no other membership carries it.
|
|
108
|
+
const base = {
|
|
109
|
+
...actor,
|
|
110
|
+
activeOrganizationId: org,
|
|
111
|
+
collection: trusted.collection,
|
|
112
|
+
id: trusted.id,
|
|
113
|
+
memberRole: undefined,
|
|
114
|
+
organizations: trusted.organizations,
|
|
115
|
+
role: trusted.role,
|
|
116
|
+
strategy: trusted.strategy,
|
|
117
|
+
};
|
|
118
|
+
delete base.permissions;
|
|
119
|
+
delete base.roles;
|
|
120
|
+
const held = await resolveAssigned({ resolver: assigned, onError, tenant: org, member: trusted.id, actor: base });
|
|
121
|
+
members.set(org, markAuthActor({ ...base, permissions: held?.permissions ?? [], roles: held?.roles ?? [] }));
|
|
122
|
+
}
|
|
123
|
+
return { reach, members };
|
|
124
|
+
};
|
|
125
|
+
const scoped = (inner) => async (args) => {
|
|
126
|
+
const field = args.kayan.tenant;
|
|
127
|
+
if (!field)
|
|
128
|
+
return inner(args);
|
|
129
|
+
// A global is one organization's row, so a reach a read further out opened never reaches it.
|
|
130
|
+
const scope = args.kayan.single ? undefined : running.getStore()?.tenant;
|
|
131
|
+
if (scope && args.effect !== 'read')
|
|
132
|
+
refuseTenantOption({ kayan: args.kayan, operation: args.operation ?? args.effect, options: { tenant: scope.reach } });
|
|
133
|
+
if (scope?.reach === 'all')
|
|
134
|
+
return inner(args);
|
|
135
|
+
if (scope?.reach === 'mine') {
|
|
136
|
+
// One branch per answer that is the same value, so organizations whose roles answer alike share one.
|
|
137
|
+
const groups = [];
|
|
138
|
+
for (const [org, member] of scope.members) {
|
|
139
|
+
let where;
|
|
140
|
+
try {
|
|
141
|
+
where = await inner({ ...args, actor: member });
|
|
142
|
+
}
|
|
143
|
+
catch (thrown) {
|
|
144
|
+
if (thrown instanceof AsasError && thrown.status === 403)
|
|
145
|
+
continue;
|
|
146
|
+
throw thrown;
|
|
147
|
+
}
|
|
148
|
+
const group = groups.find((one) => same(one.where, where));
|
|
149
|
+
if (group)
|
|
150
|
+
group.orgs.push(org);
|
|
151
|
+
else
|
|
152
|
+
groups.push({ orgs: [org], where });
|
|
153
|
+
}
|
|
154
|
+
if (!groups.length)
|
|
155
|
+
throw refused(`no organization this actor is a member of may read "${args.kayan.name}".`);
|
|
156
|
+
const branches = groups.map(({ orgs, where }) => combineWhere({ [field]: { in: orgs } }, where));
|
|
157
|
+
const answer = branches.length === 1 ? { and: [branches[0]] } : { or: branches };
|
|
158
|
+
admitted.set(answer, groups.flatMap(({ orgs }) => orgs));
|
|
159
|
+
return answer;
|
|
160
|
+
}
|
|
161
|
+
// Asked before the project's own answer, so no callback runs for an actor with no tenant.
|
|
162
|
+
const org = active(args.actor);
|
|
163
|
+
if (org === undefined)
|
|
164
|
+
throw noScope(args.kayan, args.effect);
|
|
165
|
+
const own = await inner(args);
|
|
166
|
+
return args.effect === 'create' ? own : combineWhere({ [field]: { equals: org } }, own);
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* The store a step is handed, sealed for every tenant-owned kayan it names: a row is made in
|
|
170
|
+
* the active organization alone, and an update or delete reaches that organization's rows
|
|
171
|
+
* alone and never names the field, whichever kayan the operation itself belongs to. A row
|
|
172
|
+
* it writes names a tenant-owned row of the active organization alone, so another
|
|
173
|
+
* organization's delete never reaches it.
|
|
174
|
+
*/
|
|
175
|
+
const sealWrites = ({ writes, find, kayanOf }) => {
|
|
176
|
+
// A versions table or a join table holds no tenant field to seal by, so a step reaches neither.
|
|
177
|
+
const named = (name) => {
|
|
178
|
+
const kayan = kayanOf(name);
|
|
179
|
+
if (!kayan)
|
|
180
|
+
throw new Error(`A step wrote "${name}", which names no kayan, and a step writes a kayan's rows alone: versions and join rows are core's.`);
|
|
181
|
+
return kayan;
|
|
182
|
+
};
|
|
183
|
+
const declaredField = (name, key) => {
|
|
184
|
+
if (!Object.hasOwn(named(name).fields, key))
|
|
185
|
+
throw invalid({ message: `Kayan "${name}" declares no field "${key}".`, key: 'error.invalid.value', params: { kayan: name, field: key } });
|
|
186
|
+
};
|
|
187
|
+
const checked = (name, where) => {
|
|
188
|
+
if (!where)
|
|
189
|
+
return where;
|
|
190
|
+
try {
|
|
191
|
+
return assertStoreWhere(named(name), where);
|
|
192
|
+
}
|
|
193
|
+
catch (cause) {
|
|
194
|
+
throw invalid({ message: cause instanceof Error ? cause.message : 'Invalid store filter.', key: 'error.invalid.value', params: { kayan: name }, cause });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const references = async ({ name, data, tx }) => {
|
|
198
|
+
for (const key of Object.keys(data))
|
|
199
|
+
declaredField(name, key);
|
|
200
|
+
return assertReferences({ kayan: named(name), store: { find }, data, tenant: active(running.getStore()?.user), tx });
|
|
201
|
+
};
|
|
202
|
+
const sealed = (name, data) => {
|
|
203
|
+
const field = named(name).tenant;
|
|
204
|
+
if (!field)
|
|
205
|
+
return undefined;
|
|
206
|
+
const org = active(running.getStore()?.user);
|
|
207
|
+
if (org === undefined)
|
|
208
|
+
throw new Error(`A step wrote kayan "${name}", whose rows belong to an organization, under no active organization.`);
|
|
209
|
+
if (data && field in data)
|
|
210
|
+
throw new Error(`A step wrote field "${field}" of kayan "${name}", and a row is made in its organization by the runner alone and never leaves it.`);
|
|
211
|
+
return { [field]: { equals: org } };
|
|
212
|
+
};
|
|
213
|
+
const sealedFind = async (args) => {
|
|
214
|
+
if (args.args.lock && !args.tx)
|
|
215
|
+
throw new Error('Holding rows requires a transaction.');
|
|
216
|
+
declaredField(args.kayan, args.args.sort.field);
|
|
217
|
+
return find({ ...args, args: { ...args.args, where: combineWhere(checked(args.kayan, args.args.where), sealed(args.kayan)) } });
|
|
218
|
+
};
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
find: sealedFind,
|
|
221
|
+
sum: async (args) => {
|
|
222
|
+
declaredField(args.kayan, args.field);
|
|
223
|
+
return writes.sum({ ...args, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) });
|
|
224
|
+
},
|
|
225
|
+
hold: async ({ tx, kayan, ids, mode }) => {
|
|
226
|
+
if (!['update', 'noKeyUpdate', 'share', 'keyShare'].includes(mode))
|
|
227
|
+
throw invalid({ message: 'Holding rows requires a valid lock mode.', key: 'error.invalid.value' });
|
|
228
|
+
if (!tx)
|
|
229
|
+
throw new Error('Holding rows requires a transaction.');
|
|
230
|
+
const uuid = named(kayan).fields.id?.kind === 'uuid';
|
|
231
|
+
const ordered = [...new Set(ids.map((id) => uuid && typeof id === 'string' ? id.toLowerCase() : id))].sort((left, right) => compareValues({ left, right, direction: 'asc' }));
|
|
232
|
+
for (const id of ordered) {
|
|
233
|
+
const { docs } = await sealedFind({ tx, kayan, args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, lock: mode, where: { id: { equals: id } } } });
|
|
234
|
+
if (!docs.length)
|
|
235
|
+
throw missing({ message: `No "${kayan}" row found.`, key: 'error.not_found.row', params: { kayan } });
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
upsert: async (args) => {
|
|
239
|
+
const field = named(args.kayan).tenant;
|
|
240
|
+
const org = active(running.getStore()?.user);
|
|
241
|
+
if (field && (org === undefined || (Object.hasOwn(args.data, field) && args.data[field] !== org))) {
|
|
242
|
+
throw new Error(`A step upserted kayan "${args.kayan}" in another organization than the active one.`);
|
|
243
|
+
}
|
|
244
|
+
const data = field ? { ...args.data, [field]: org } : args.data;
|
|
245
|
+
const kayan = named(args.kayan);
|
|
246
|
+
if (!args.on.length || new Set(args.on).size !== args.on.length || !(args.on.length === 1 && args.on[0] === 'id') && !kayan.indexes.some((index) => index.unique && index.fields.length === args.on.length && index.fields.every((field) => args.on.includes(field))) || args.on.some((key) => !Object.hasOwn(data, key) || (data[key] && typeof data[key] === 'object' && '$inc' in data[key]))) {
|
|
247
|
+
throw invalid({ message: `Kayan "${args.kayan}" upsert needs plain key values for a declared unique index in "on".`, key: 'error.invalid.value', params: { kayan: args.kayan } });
|
|
248
|
+
}
|
|
249
|
+
for (const key of args.on)
|
|
250
|
+
declaredField(args.kayan, key);
|
|
251
|
+
await references({ name: args.kayan, data, tx: args.tx });
|
|
252
|
+
return writes.upsert({ ...args, data, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) });
|
|
253
|
+
},
|
|
254
|
+
insert: async (args) => {
|
|
255
|
+
const field = named(args.kayan).tenant;
|
|
256
|
+
const org = active(running.getStore()?.user);
|
|
257
|
+
if (field) {
|
|
258
|
+
if (org === undefined || (Object.hasOwn(args.data, field) && args.data[field] !== org)) {
|
|
259
|
+
throw new Error(`A step made a row of kayan "${args.kayan}" in another organization than the active one, and a row is made in its own organization alone.`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const data = field ? { ...args.data, [field]: org } : args.data;
|
|
263
|
+
await references({ name: args.kayan, data, tx: args.tx });
|
|
264
|
+
return writes.insert({ ...args, data });
|
|
265
|
+
},
|
|
266
|
+
update: async (args) => {
|
|
267
|
+
const where = combineWhere(checked(args.kayan, args.where), sealed(args.kayan, args.data));
|
|
268
|
+
await references({ name: args.kayan, data: args.data, tx: args.tx });
|
|
269
|
+
return writes.update({ ...args, where });
|
|
270
|
+
},
|
|
271
|
+
remove: async (args) => writes.remove({ ...args, where: combineWhere(checked(args.kayan, args.where), sealed(args.kayan)) }),
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
return { open, scoped, active, sealWrites };
|
|
275
|
+
};
|