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