@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,178 @@
|
|
|
1
|
+
import { AfterCommitError, AsasError, afterCommitHeader, codes, retryConflict } from '../runner/errors.js';
|
|
2
|
+
// The walk enters objects, arrays and the wrappers `unwrap()` opens, and nothing else.
|
|
3
|
+
const datesFromJson = (value, schema) => {
|
|
4
|
+
if (schema.type === 'date' && typeof value === 'string') {
|
|
5
|
+
// Date.parse normalizes February 30, which the declared z.date must refuse.
|
|
6
|
+
const day = value.slice(0, 10);
|
|
7
|
+
if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,9})?(?:Z|[+-]\d\d:\d\d)$/.test(value)
|
|
8
|
+
&& Number(value.slice(11, 13)) < 24) {
|
|
9
|
+
const parsed = new Date(value);
|
|
10
|
+
const midnight = new Date(`${day}T00:00:00Z`);
|
|
11
|
+
if (!Number.isNaN(parsed.getTime()) && !Number.isNaN(midnight.getTime()) && midnight.toISOString().slice(0, 10) === day)
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (['optional', 'nullable', 'default', 'prefault', 'nonoptional', 'readonly'].includes(schema.type ?? '')) {
|
|
16
|
+
return schema.unwrap ? datesFromJson(value, schema.unwrap()) : value;
|
|
17
|
+
}
|
|
18
|
+
if (schema.type === 'array' && Array.isArray(value) && schema.element) {
|
|
19
|
+
return value.map((one) => datesFromJson(one, schema.element));
|
|
20
|
+
}
|
|
21
|
+
if (schema.type === 'object' && value !== null && typeof value === 'object' && !Array.isArray(value) && schema.shape) {
|
|
22
|
+
const result = { ...value };
|
|
23
|
+
for (const [name, child] of Object.entries(schema.shape)) {
|
|
24
|
+
if (Object.hasOwn(result, name))
|
|
25
|
+
result[name] = datesFromJson(result[name], child);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
/** The Zod input decides which JSON strings represent dates on either HTTP plug. */
|
|
32
|
+
export const decodeJson = (value, schema) => schema['~standard'].vendor === 'zod' ? datesFromJson(value, schema) : value;
|
|
33
|
+
// `?draft=true` saves or reads a draft, and `?autosave=true` beside it lets the
|
|
34
|
+
// save overwrite an autosave. `?tenant=` is a read's reach, which the runner judges.
|
|
35
|
+
export const draftOptions = (request) => {
|
|
36
|
+
const query = new URL(request.url).searchParams;
|
|
37
|
+
return {
|
|
38
|
+
...(query.get('draft') === 'true' ? { draft: true } : {}),
|
|
39
|
+
...(query.get('autosave') === 'true' ? { autosave: true } : {}),
|
|
40
|
+
...(query.has('tenant') ? { tenant: query.get('tenant') } : {}),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The largest request body a plug reads, which a project names as `maxBodyBytes`: room for a
|
|
45
|
+
* json field's `defaultJsonMaxBytes` written in any script. A file never comes this way.
|
|
46
|
+
*/
|
|
47
|
+
export const defaultMaxBodyBytes = 4 * 1024 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* The body, or the answer that refuses it: 413 once it passes `limit` bytes, and 499 to a client
|
|
50
|
+
* that went away mid-body, which is no fault of the server's. Counted as it arrives, since a
|
|
51
|
+
* `content-length` may be absent or wrong, and the rest is never read.
|
|
52
|
+
*/
|
|
53
|
+
export const boundedBody = async (request, limit) => {
|
|
54
|
+
const reader = request.body?.getReader();
|
|
55
|
+
const refused = () => {
|
|
56
|
+
// Not awaited: a clone's cancel settles only once its twin is cancelled too, which the
|
|
57
|
+
// caller does after this returns.
|
|
58
|
+
reader?.cancel().catch(() => { });
|
|
59
|
+
return tooLarge(limit);
|
|
60
|
+
};
|
|
61
|
+
if (Number(request.headers.get('content-length')) > limit)
|
|
62
|
+
return refused();
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
for (;;) {
|
|
66
|
+
let read;
|
|
67
|
+
try {
|
|
68
|
+
read = (await reader?.read()) ?? { done: true, value: undefined };
|
|
69
|
+
}
|
|
70
|
+
catch (thrown) {
|
|
71
|
+
if (request.signal.aborted)
|
|
72
|
+
return new Response(null, { status: 499 });
|
|
73
|
+
throw thrown;
|
|
74
|
+
}
|
|
75
|
+
if (read.done)
|
|
76
|
+
return new Blob(chunks);
|
|
77
|
+
size += read.value.byteLength;
|
|
78
|
+
if (size > limit)
|
|
79
|
+
return refused();
|
|
80
|
+
chunks.push(read.value);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** Refused by name, since core holds no limit of its own. */
|
|
84
|
+
export const assertMaxBodyBytes = (owner, value) => {
|
|
85
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
86
|
+
throw new Error(`${owner} names no "maxBodyBytes", the largest request body in bytes it reads, as a whole number above 0. Name the project's own, such as defaultMaxBodyBytes from mithaq.`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/** Refused by name, as `auth.onError` and `storage.onError` are: core reports no fault of its own. */
|
|
90
|
+
export const assertOnError = (owner, value) => {
|
|
91
|
+
if (typeof value !== 'function') {
|
|
92
|
+
throw new Error(`${owner} names no "onError" function, which it hands every error that is no refusal before it answers 500. Name the project's own, such as console.error.`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
export const tooLarge = (limit) => Response.json({ code: codes[413], message: `The request body holds at most ${limit} bytes.` }, { status: 413 });
|
|
96
|
+
/**
|
|
97
|
+
* The default HTTP plug: `POST /api/<kayan>/<operation>` with a JSON body, over
|
|
98
|
+
* `Request` and `Response` alone. The runner parses each operation's input.
|
|
99
|
+
*/
|
|
100
|
+
export const fetchHttp = (options) => {
|
|
101
|
+
// Read off `options?`, since a JavaScript caller may hand nothing, and is refused by name.
|
|
102
|
+
assertMaxBodyBytes('fetchHttp', options?.maxBodyBytes);
|
|
103
|
+
assertOnError('fetchHttp', options.onError);
|
|
104
|
+
const { context, onError, maxBodyBytes } = options;
|
|
105
|
+
return (operations) => {
|
|
106
|
+
const table = new Map(operations.map((one) => [`${one.kayan}/${one.name}`, one]));
|
|
107
|
+
return {
|
|
108
|
+
name: 'fetch',
|
|
109
|
+
// The operation table is the routes, already published as `asas.operations`.
|
|
110
|
+
router: undefined,
|
|
111
|
+
handle: async (request, actor) => {
|
|
112
|
+
if (request.method !== 'POST')
|
|
113
|
+
return null;
|
|
114
|
+
// Two segments under `/api` and no more, so another plug's three-segment prefix
|
|
115
|
+
// falls through. `null`, not a 404, so a host keeps its own routes.
|
|
116
|
+
const [api, kayan, name, ...rest] = new URL(request.url).pathname.split('/').slice(1);
|
|
117
|
+
if (api !== 'api' || !kayan || !name || rest.length > 0)
|
|
118
|
+
return null;
|
|
119
|
+
const operation = table.get(`${kayan}/${name}`);
|
|
120
|
+
if (!operation)
|
|
121
|
+
return null;
|
|
122
|
+
const read = await boundedBody(request, maxBodyBytes);
|
|
123
|
+
if (read instanceof Response)
|
|
124
|
+
return read;
|
|
125
|
+
const body = await read.text();
|
|
126
|
+
// Keyless: refused before any operation, and the key names an operation's refusal.
|
|
127
|
+
const undecodable = (message) => Response.json({ code: codes[400], message }, { status: 400 });
|
|
128
|
+
let input = {};
|
|
129
|
+
if (body) {
|
|
130
|
+
try {
|
|
131
|
+
input = JSON.parse(body);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undecodable('The request body is not JSON.');
|
|
135
|
+
}
|
|
136
|
+
// The runner reads fields off the input, so a body of `null` or `1` would be a
|
|
137
|
+
// 500 a caller provoked; every caller-provoked refusal is a 4xx.
|
|
138
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
139
|
+
return undecodable('The request body is not a JSON object.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
// Resolved after the match, so a host's own routes cost no session lookup.
|
|
144
|
+
const decoded = decodeJson(input, operation.input);
|
|
145
|
+
const who = await actor();
|
|
146
|
+
const result = await operation.call(decoded, { actor: who, context: await context?.(request, who), ...draftOptions(request) });
|
|
147
|
+
// Only a write says so, because the browser clears its banner on this value alone.
|
|
148
|
+
return Response.json(result, operation.effect === 'read' ? undefined : { headers: { [afterCommitHeader]: 'ok' } });
|
|
149
|
+
}
|
|
150
|
+
catch (thrown) {
|
|
151
|
+
if (thrown instanceof AfterCommitError) {
|
|
152
|
+
try {
|
|
153
|
+
onError(thrown.cause);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Reporting cannot change a committed result.
|
|
157
|
+
}
|
|
158
|
+
return Response.json(thrown.result, { headers: { [afterCommitHeader]: 'failed' } });
|
|
159
|
+
}
|
|
160
|
+
thrown = retryConflict(thrown);
|
|
161
|
+
if (thrown instanceof AsasError) {
|
|
162
|
+
// The cause is never serialized: a conflict's driver error names the column and
|
|
163
|
+
// value the refusal's message withholds.
|
|
164
|
+
return Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
onError(thrown);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Deliberately empty: reporting may not change what is reported.
|
|
171
|
+
}
|
|
172
|
+
// Not an `AsasError` is the project's own fault, so its message is replaced.
|
|
173
|
+
return Response.json({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' }, { status: 500 });
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Procedure, Schema } from '@orpc/server';
|
|
2
|
+
import type { OperationTable } from '../build.ts';
|
|
3
|
+
/** The context every procedure is handed, which defaults to the actor alone. */
|
|
4
|
+
export type OrpcContext = {
|
|
5
|
+
actor: unknown;
|
|
6
|
+
} & Record<string, unknown>;
|
|
7
|
+
export type OrpcHttpOptions = {
|
|
8
|
+
/** Where the typed protocol answers. */
|
|
9
|
+
rpc?: `/${string}`;
|
|
10
|
+
/** Where plain HTTP answers, one route per operation. */
|
|
11
|
+
api?: `/${string}`;
|
|
12
|
+
/**
|
|
13
|
+
* Builds the context a procedure receives, once per request. The default is `{ actor }`;
|
|
14
|
+
* the `actor` it returns is the one the call runs as.
|
|
15
|
+
*/
|
|
16
|
+
context?: (request: Request, actor: unknown) => Promise<OrpcContext> | OrpcContext;
|
|
17
|
+
/**
|
|
18
|
+
* Handed every error that is not an `AsasError`, before it is rethrown with its message replaced.
|
|
19
|
+
* A host's own deliberate `ORPCError` reaches it too, so a host logging only faults filters on the code. Required.
|
|
20
|
+
*/
|
|
21
|
+
onError: (error: unknown) => void;
|
|
22
|
+
/** The largest body a call takes, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required. */
|
|
23
|
+
maxBodyBytes: number;
|
|
24
|
+
};
|
|
25
|
+
type OrpcProcedure<Method> = Method extends (...args: infer Args) => Promise<infer Answer> ? Procedure<OrpcContext, OrpcContext, Schema<Args[0], Args[0]>, Schema<Answer, Answer>, Record<never, never>, Record<never, never>> : never;
|
|
26
|
+
/** The router, typed from the operation table: kayan, then operation. `RouterClient` over it needs no generated code. */
|
|
27
|
+
export type OrpcRouter<View> = {
|
|
28
|
+
[Kayan in keyof View]: {
|
|
29
|
+
[Name in keyof View[Kayan]]: OrpcProcedure<View[Kayan][Name]>;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The oRPC plug: one procedure per operation, served over both the typed RPC protocol
|
|
34
|
+
* and plain HTTP. Needs `@orpc/server` and `@orpc/openapi`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const orpcHttp: (options: OrpcHttpOptions) => <View>(operations: OperationTable<View>) => {
|
|
37
|
+
name: string;
|
|
38
|
+
router: OrpcRouter<View>;
|
|
39
|
+
/**
|
|
40
|
+
* `null` when neither prefix matched, so a host keeps its own routes. The path is matched
|
|
41
|
+
* before the actor thunk is called, so a host's own route runs no strategy.
|
|
42
|
+
*/
|
|
43
|
+
handle: (request: Request, actor: () => Promise<unknown>) => Promise<Response | null>;
|
|
44
|
+
};
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The only file that imports oRPC, so it is its own entry and oRPC stays an
|
|
2
|
+
// optional peer a project that serves no HTTP never installs.
|
|
3
|
+
import { OpenAPIHandler } from '@orpc/openapi/fetch';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { ORPCError, ValidationError, os } from '@orpc/server';
|
|
6
|
+
import { RPCHandler } from '@orpc/server/fetch';
|
|
7
|
+
import { AfterCommitError, AsasError, afterCommitHeader, codes, retryConflict } from '../runner/errors.js';
|
|
8
|
+
import { issueRefusal, withoutPrototype } from '../runner/steps.js';
|
|
9
|
+
import { assertMaxBodyBytes, assertOnError, boundedBody, decodeJson, draftOptions } from './fetch.js';
|
|
10
|
+
// Matched on the `ValidationError` cause, not the shape of `data`, so a host's own
|
|
11
|
+
// `BAD_REQUEST` keeps its message and data.
|
|
12
|
+
const boundaryRefusal = (thrown) => thrown instanceof ORPCError && thrown.code === 'BAD_REQUEST' && thrown.cause instanceof ValidationError
|
|
13
|
+
? issueRefusal(thrown.cause.issues)
|
|
14
|
+
: undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The oRPC plug: one procedure per operation, served over both the typed RPC protocol
|
|
17
|
+
* and plain HTTP. Needs `@orpc/server` and `@orpc/openapi`.
|
|
18
|
+
*/
|
|
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) => {
|
|
27
|
+
try {
|
|
28
|
+
onError(thrown);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Deliberately empty: reporting may not change what is reported.
|
|
32
|
+
}
|
|
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 }) => {
|
|
38
|
+
try {
|
|
39
|
+
return await next();
|
|
40
|
+
}
|
|
41
|
+
catch (thrown) {
|
|
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) {
|
|
47
|
+
report(thrown);
|
|
48
|
+
throw thrown;
|
|
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
|
+
});
|
|
57
|
+
}
|
|
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
|
+
};
|
|
152
|
+
};
|
|
153
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ResolvedOperation, type RuntimeWriteTools as WriteTools } from './steps.ts';
|
|
2
|
+
import { type Authorize, type ManagedAdmin, type Options } from './write.ts';
|
|
3
|
+
import type { ResolvedKayan } from '../../mithaq/types.ts';
|
|
4
|
+
import type { Store } from '../../mithaq/language.ts';
|
|
5
|
+
export type SingleWrite<Actor> = (data: Record<string, unknown>, options?: Options<Actor>) => Promise<unknown>;
|
|
6
|
+
export declare const bulk: <Actor = unknown>({ kayan, declaration, tools, store, single, cap, bulkLimit: ceiling, authorize, admin, tenantOf, }: {
|
|
7
|
+
kayan: ResolvedKayan;
|
|
8
|
+
declaration: ResolvedOperation;
|
|
9
|
+
tools: WriteTools<unknown, Actor>;
|
|
10
|
+
store: Store;
|
|
11
|
+
single: SingleWrite<Actor>;
|
|
12
|
+
cap: number;
|
|
13
|
+
/**
|
|
14
|
+
* The most rows one call may match, the service target's `bulkLimit`.
|
|
15
|
+
*/
|
|
16
|
+
bulkLimit: number;
|
|
17
|
+
authorize: Authorize;
|
|
18
|
+
admin?: ManagedAdmin<Actor>;
|
|
19
|
+
tenantOf: (actor: unknown) => string | undefined;
|
|
20
|
+
}) => (given: Record<string, unknown>, options?: Options<Actor>) => Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// One transaction for every row: a row that cannot be written rolls the whole call
|
|
2
|
+
// back, because a bulk that half happened is a state no client can undo.
|
|
3
|
+
import { stepTools } from './store.js';
|
|
4
|
+
import { AfterCommitError, AsasError, invalid, retryConflict } from './errors.js';
|
|
5
|
+
import { assertReadableWhere, checkedWhere } from './read.js';
|
|
6
|
+
import { running } from './runtime.js';
|
|
7
|
+
import { refuseTenantOption, writeTenant } from './tenant.js';
|
|
8
|
+
import { atEffect, parse, validated } from './steps.js';
|
|
9
|
+
import { aliveWhere, beginWrite, drain, ontoRow, projectReadableFields, publishedWhere, rowLock, runOutside } from './write.js';
|
|
10
|
+
import { combineWhere } from '../../mithaq/where.js';
|
|
11
|
+
// The bulk's own key is asked once, then each row goes through the single-row
|
|
12
|
+
// write, which asks its own key and field access: no key grants more through a bulk.
|
|
13
|
+
export const bulk = ({ kayan, declaration, tools, store, single, cap, bulkLimit: ceiling, authorize, admin, tenantOf, }) => async (given, options) => {
|
|
14
|
+
const list = atEffect(declaration.steps);
|
|
15
|
+
// Each row lands on the row as it is, so `{ draft: true }` is refused rather than published.
|
|
16
|
+
if (kayan.versions?.drafts && options?.draft) {
|
|
17
|
+
throw invalid({
|
|
18
|
+
message: `Kayan "${kayan.name}" operation "${tools.operation}" writes each row as it is, so it saves no draft. Call it without draft.`,
|
|
19
|
+
key: 'error.invalid.draft_operation',
|
|
20
|
+
params: { kayan: kayan.name, operation: tools.operation },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const { actor, context, entry, open } = beginWrite({ kayan, operation: tools.operation, store, cap, options });
|
|
24
|
+
refuseTenantOption({ kayan, operation: tools.operation, options });
|
|
25
|
+
const owner = writeTenant({ kayan, active: tenantOf(actor), effect: tools.effect, data: given?.data });
|
|
26
|
+
// Asked once, before `beforeTransaction`, so a refused actor runs no outside step.
|
|
27
|
+
const accessWhere = await authorize({
|
|
28
|
+
admin,
|
|
29
|
+
actor,
|
|
30
|
+
data: {},
|
|
31
|
+
effect: tools.effect,
|
|
32
|
+
kayan,
|
|
33
|
+
kind: tools.kind,
|
|
34
|
+
operation: tools.operation,
|
|
35
|
+
});
|
|
36
|
+
// Checked before the transaction opens, so a value a column refuses is a 400 no store was asked for.
|
|
37
|
+
if (given?.where !== undefined)
|
|
38
|
+
await checkedWhere(kayan, given.where);
|
|
39
|
+
let values = given;
|
|
40
|
+
if (declaration.beforeTransaction.length) {
|
|
41
|
+
const before = await runOutside({
|
|
42
|
+
kayan,
|
|
43
|
+
tools,
|
|
44
|
+
slot: 'beforeTransaction',
|
|
45
|
+
list: atEffect(declaration.beforeTransaction),
|
|
46
|
+
start: { kayan, data: { ...given }, actor, user: actor, tx: undefined, context, tenant: owner },
|
|
47
|
+
around: { context, user: actor },
|
|
48
|
+
});
|
|
49
|
+
values = before.data;
|
|
50
|
+
}
|
|
51
|
+
let committed;
|
|
52
|
+
const many = async (data, tx) => {
|
|
53
|
+
const callerWhere = await assertReadableWhere({ kayan, where: data.where, actor, authorize, admin });
|
|
54
|
+
// Required here: a bulk with no `where` would be every row the access admits.
|
|
55
|
+
if (!callerWhere) {
|
|
56
|
+
throw invalid({
|
|
57
|
+
message: `Kayan "${kayan.name}" operation "${tools.operation}" needs a "where", because a bulk write with none would change every row the access admits.`,
|
|
58
|
+
key: 'error.invalid.bulk_where',
|
|
59
|
+
params: { kayan: kayan.name, operation: tools.operation },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// An update naming no column would move each row's `updatedAt` and keep a version
|
|
63
|
+
// for no edit. Asked after the steps, since a step may fill `data`.
|
|
64
|
+
if (tools.kind === 'updateMany' && !Object.keys((data.data ?? {})).length) {
|
|
65
|
+
throw invalid({
|
|
66
|
+
message: `Kayan "${kayan.name}" operation "${tools.operation}" was handed no "data", so there is nothing to change.`,
|
|
67
|
+
key: 'error.invalid.empty_update',
|
|
68
|
+
params: { kayan: kayan.name, operation: tools.operation },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// The rows a plain read shows, so a bulk never reaches a draft that was never published.
|
|
72
|
+
const where = combineWhere(combineWhere(callerWhere, accessWhere), publishedWhere(kayan));
|
|
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.
|
|
76
|
+
const found = await store.find({
|
|
77
|
+
kayan: kayan.name,
|
|
78
|
+
args: { limit: ceiling, page: 1, lock: rowLock({ kayan, kind: tools.kind, data: values }), sort: { field: 'id', direction: 'asc' }, where },
|
|
79
|
+
tx,
|
|
80
|
+
});
|
|
81
|
+
// Refused rather than written in part: a caller could not tell which rows a partial
|
|
82
|
+
// bulk wrote. The caller narrows its `where` instead.
|
|
83
|
+
if (found.totalDocs > ceiling) {
|
|
84
|
+
throw invalid({
|
|
85
|
+
message: `Kayan "${kayan.name}" operation "${tools.operation}" matched ${found.totalDocs} rows, and a bulk write holds at most ${ceiling} in one transaction. Narrow the "where" and run it again.`,
|
|
86
|
+
key: 'error.invalid.bulk_limit',
|
|
87
|
+
params: { kayan: kayan.name, operation: tools.operation, matched: found.totalDocs, ceiling },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const docs = [];
|
|
91
|
+
// In order, not in parallel: each write joins this transaction, which is one connection.
|
|
92
|
+
for (const row of found.docs) {
|
|
93
|
+
// Onto the row as it is, never its pending draft.
|
|
94
|
+
docs.push((await single({ ...values, id: row.id }, { actor: actor ?? undefined, [ontoRow]: true })));
|
|
95
|
+
}
|
|
96
|
+
// Unpublished rows the `where` names are left alone and reported under `errors`, so a
|
|
97
|
+
// person who ticked one is told; only to an actor who may read drafts, and only rows its answer admits.
|
|
98
|
+
const admits = kayan.versions?.drafts
|
|
99
|
+
? await authorize({ admin, actor, data: {}, effect: 'read', kayan, operation: 'findVersions' }).then((where) => ({ where }), (thrown) => {
|
|
100
|
+
if (thrown instanceof AsasError && thrown.status === 403)
|
|
101
|
+
return undefined;
|
|
102
|
+
throw thrown;
|
|
103
|
+
})
|
|
104
|
+
: undefined;
|
|
105
|
+
const unpublished = admits
|
|
106
|
+
? (await store.find({
|
|
107
|
+
kayan: kayan.name,
|
|
108
|
+
args: { limit: ceiling, page: 1, sort: { field: 'id', direction: 'asc' }, where: combineWhere(combineWhere(callerWhere, accessWhere), combineWhere(aliveWhere(kayan), combineWhere({ _status: { not_equals: 'published' } }, admits.where))) },
|
|
109
|
+
tx,
|
|
110
|
+
})).docs
|
|
111
|
+
: [];
|
|
112
|
+
return {
|
|
113
|
+
docs,
|
|
114
|
+
errors: unpublished.map((row) => ({ id: row.id, message: `Kayan "${kayan.name}" row is not published, so operation "${tools.operation}" left it as it is. Publish it first.` })),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const run = (tx) => {
|
|
118
|
+
entry.tx = tx;
|
|
119
|
+
return running.run({ context, transactions: new Map(open).set(store, entry), user: actor }, async () => {
|
|
120
|
+
const runTools = Object.freeze({ ...tools, many: (data) => many(data, tx) });
|
|
121
|
+
const publicTools = stepTools(runTools);
|
|
122
|
+
let ctx = { kayan, data: { ...values }, actor, user: actor, tx, context, tenant: owner };
|
|
123
|
+
// No defaults here: each row's single-row write fills its own.
|
|
124
|
+
ctx = await parse({ schema: runTools.schema, ctx, tools: publicTools });
|
|
125
|
+
for (const step of atEffect([
|
|
126
|
+
...declaration.beforeSteps,
|
|
127
|
+
...list,
|
|
128
|
+
...declaration.afterSteps,
|
|
129
|
+
]))
|
|
130
|
+
ctx = { ...(await step(ctx, publicTools)), tenant: owner };
|
|
131
|
+
committed = ctx;
|
|
132
|
+
// Each document already passed its single-row output and read mask.
|
|
133
|
+
// The bulk contract validates the envelope after the steps have run.
|
|
134
|
+
if (!tools.publicOutput)
|
|
135
|
+
return ctx.data;
|
|
136
|
+
const parsed = await validated(tools.publicOutput, ctx.data);
|
|
137
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
138
|
+
throw new Error(`Kayan "${kayan.name}" custom bulk output must return an object.`);
|
|
139
|
+
}
|
|
140
|
+
const result = parsed;
|
|
141
|
+
if ('docs' in result) {
|
|
142
|
+
if (!Array.isArray(result.docs))
|
|
143
|
+
throw new Error(`Kayan "${kayan.name}" custom bulk output must return docs as an array.`);
|
|
144
|
+
for (const doc of result.docs) {
|
|
145
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
146
|
+
throw new Error(`Kayan "${kayan.name}" custom bulk output must return row objects.`);
|
|
147
|
+
const visible = await projectReadableFields({ kayan, row: doc, actor });
|
|
148
|
+
const protectedField = Object.keys(doc).find((key) => !(key in visible));
|
|
149
|
+
if (protectedField)
|
|
150
|
+
throw new Error(`Kayan "${kayan.name}" custom bulk output returned protected field "${protectedField}" after read projection.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
const afterCommit = async () => {
|
|
157
|
+
if (!declaration.afterCommit.length || !committed)
|
|
158
|
+
return;
|
|
159
|
+
await runOutside({
|
|
160
|
+
kayan,
|
|
161
|
+
tools,
|
|
162
|
+
slot: 'afterCommit',
|
|
163
|
+
list: atEffect(declaration.afterCommit),
|
|
164
|
+
start: { ...committed, tx: undefined },
|
|
165
|
+
around: { context, user: actor },
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const joined = open.get(store);
|
|
169
|
+
if (joined) {
|
|
170
|
+
const result = await run(joined.tx);
|
|
171
|
+
if (declaration.afterCommit.length)
|
|
172
|
+
joined.afterCommit = [...(joined.afterCommit ?? []), afterCommit];
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = await store.transaction(run);
|
|
178
|
+
}
|
|
179
|
+
catch (thrown) {
|
|
180
|
+
throw retryConflict(thrown);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
entry.settled = true;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await drain([...(entry.afterCommit ?? []), afterCommit]);
|
|
187
|
+
}
|
|
188
|
+
catch (cause) {
|
|
189
|
+
// Only the outermost operation answers with what it committed; under an outer one
|
|
190
|
+
// the failure climbs as a plain `Error`.
|
|
191
|
+
if (running.getStore())
|
|
192
|
+
throw cause;
|
|
193
|
+
throw new AfterCommitError(result, cause);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export declare const codes: {
|
|
2
|
+
readonly 400: 'BAD_REQUEST';
|
|
3
|
+
readonly 401: 'UNAUTHORIZED';
|
|
4
|
+
readonly 403: 'FORBIDDEN';
|
|
5
|
+
readonly 404: 'NOT_FOUND';
|
|
6
|
+
readonly 409: 'CONFLICT';
|
|
7
|
+
readonly 413: 'PAYLOAD_TOO_LARGE';
|
|
8
|
+
readonly 429: 'TOO_MANY_REQUESTS';
|
|
9
|
+
readonly 503: 'SERVICE_UNAVAILABLE';
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A refusal the caller caused, with the HTTP status, a message `key` a client
|
|
13
|
+
* translates, and the `params` the message names. The English message is for logs.
|
|
14
|
+
*/
|
|
15
|
+
export declare class AsasError extends Error {
|
|
16
|
+
readonly status: 400 | 403 | 404 | 409;
|
|
17
|
+
readonly key: MessageKey;
|
|
18
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
19
|
+
constructor(message: string, status: 400 | 403 | 404 | 409, key: MessageKey, params?: Record<string, unknown>, cause?: unknown);
|
|
20
|
+
}
|
|
21
|
+
/** A write committed, but one or more callbacks that followed it failed. */
|
|
22
|
+
export declare class AfterCommitError extends Error {
|
|
23
|
+
readonly committed = true;
|
|
24
|
+
readonly result: unknown;
|
|
25
|
+
constructor(result: unknown, cause: unknown);
|
|
26
|
+
}
|
|
27
|
+
/** Something a project is told that is expected rather than a failure, handed to the same `onError`. */
|
|
28
|
+
export declare class Notice extends Error {
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
31
|
+
/** A generic wire signal; callback details remain in the server reporter. */
|
|
32
|
+
export declare const afterCommitHeader = "x-mithaq-after-commit";
|
|
33
|
+
/** A refusal's key in the message namespace. */
|
|
34
|
+
export type MessageKey = `error.${'conflict' | 'forbidden' | 'invalid' | 'not_found'}.${string}`;
|
|
35
|
+
/** What every refusal takes: the English a log reads, the key a client translates, the values it names, and the cause. */
|
|
36
|
+
export type Refusal = {
|
|
37
|
+
message: string;
|
|
38
|
+
key: MessageKey;
|
|
39
|
+
params?: Record<string, unknown>;
|
|
40
|
+
cause?: unknown;
|
|
41
|
+
};
|
|
42
|
+
/** The caller may not do this. */
|
|
43
|
+
export declare const denied: ({ message, key, params, cause }: Refusal) => AsasError;
|
|
44
|
+
/** The caller sent something the schema or the shape refuses. */
|
|
45
|
+
export declare const invalid: ({ message, key, params, cause }: Refusal) => AsasError;
|
|
46
|
+
/**
|
|
47
|
+
* The row the caller addressed is not there. An absent row and one access
|
|
48
|
+
* excludes answer alike, so a 404 never says which.
|
|
49
|
+
*/
|
|
50
|
+
export declare const missing: ({ message, key, params, cause }: Refusal) => AsasError;
|
|
51
|
+
/**
|
|
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.
|
|
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;
|
|
58
|
+
export declare const localizedQuery: ({ kayan, field, what, locales, }: {
|
|
59
|
+
kayan: string;
|
|
60
|
+
field: string;
|
|
61
|
+
what: 'filter' | 'sort';
|
|
62
|
+
locales?: readonly string[];
|
|
63
|
+
}) => string;
|