@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/mithaq/define.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type FieldList, type RowFieldWhere } from './rows.ts';
|
|
2
2
|
import type { Ref } from './user.ts';
|
|
3
|
-
import { type ContextOf, type DefaultLists, type OperationDeclaration, type SchemasOf } from '../asas/runner/steps.ts';
|
|
3
|
+
import { defaultGlobalOperations, defaultOperations, type ContextOf, type DefaultLists, type OperationDeclaration, type SchemasOf } from '../asas/runner/steps.ts';
|
|
4
|
+
import { defaultFields, defaultGlobalFields } from '../base/fields.ts';
|
|
5
|
+
import { draftFields, versionOperations } from '../versions/versions.ts';
|
|
6
|
+
import type { KayanVersions } from '../versions/index.ts';
|
|
4
7
|
import type { DerivedInput, OperationParsed } from '../asas/runner/write.ts';
|
|
5
8
|
import type { Language } from './language.ts';
|
|
6
9
|
import type { ActorBoundAccess, Access, Field, FieldOf, Indexes, KayanService, KindValue, LocalesOf, MessageTree, RuntimeField, ServiceOf } from './types.ts';
|
|
@@ -14,6 +17,7 @@ type KayanAuthOf<Targets> = AuthStrategy | {
|
|
|
14
17
|
server: (targets: Targets, vocabulary: {
|
|
15
18
|
catalog: readonly string[];
|
|
16
19
|
statements: Readonly<Record<string, readonly string[]>>;
|
|
20
|
+
referenced: (id: string) => Promise<number>;
|
|
17
21
|
}) => unknown;
|
|
18
22
|
};
|
|
19
23
|
type StrayLocales<Copy, Locale extends string> = {
|
|
@@ -125,6 +129,45 @@ type NamedSchema<Name> = [Name] extends [undefined] ? {
|
|
|
125
129
|
} : {
|
|
126
130
|
schema: Name;
|
|
127
131
|
};
|
|
132
|
+
type CheckedFields<L extends Language, Fields extends Record<string, FieldOf<L>>> = Fields & {
|
|
133
|
+
[N in keyof Fields]: {
|
|
134
|
+
[Key in Exclude<keyof Fields[N], keyof Field<string, L['rules']>>]: never;
|
|
135
|
+
} & StrayLocales<Fields[N]['label'], LocalesOf<L>> & StrayDescription<Fields[N], LocalesOf<L>> & StrayPlaceholder<Fields[N], LocalesOf<L>> & TenantBinding<L, Fields[N]>;
|
|
136
|
+
};
|
|
137
|
+
type AnyFunction = (...args: never) => unknown;
|
|
138
|
+
/**
|
|
139
|
+
* `operations` written as data `O`, or as a function of the defaults whose result is `R`. The two
|
|
140
|
+
* are told apart by `O` having no keys, never by a union: under a union TypeScript stops checking a
|
|
141
|
+
* written object for excess properties, and a misspelled key would pass. A stray key in a returned
|
|
142
|
+
* object, which no excess check reaches, is refused by name.
|
|
143
|
+
*/
|
|
144
|
+
type DeclaredOperations<L extends Language, Fields extends Record<string, RuntimeField>, O, R, Defaults> = O & ([keyof O] extends [never] ? ((args: {
|
|
145
|
+
defaults: Defaults;
|
|
146
|
+
}) => R & NoInfer<Record<string, OperationOf<L, Fields, never>> & {
|
|
147
|
+
[Name in keyof R]: OperationOf<L, Fields, DataOf<L, Fields, R[Name]>> & {
|
|
148
|
+
[Key in Exclude<keyof R[Name], keyof OperationOf<L, Fields, never>>]?: never;
|
|
149
|
+
};
|
|
150
|
+
}>) | NoInfer<Record<string, OperationOf<L, Fields, never>>> : NoInfer<Record<string, OperationOf<L, Fields, never>>> & NoInfer<{
|
|
151
|
+
[Name in keyof O]: OperationOf<L, Fields, DataOf<L, Fields, O[Name]>>;
|
|
152
|
+
}>);
|
|
153
|
+
/** The operations a kayan declared, whichever way it wrote them. */
|
|
154
|
+
type OperationsOf<O, R> = [keyof O] extends [never] ? ([O] extends [AnyFunction] ? R : O) : O;
|
|
155
|
+
/** Data, or a function of what the language offers that returns it, run once where the kayan is declared. */
|
|
156
|
+
type OverDefaults<Declared, Defaults> = Declared | ((args: {
|
|
157
|
+
defaults: Defaults;
|
|
158
|
+
}) => Declared);
|
|
159
|
+
/** The name of the language's versions target, which a kayan keeps versions through by writing an entry under it. */
|
|
160
|
+
type VersionsKey<L extends Language> = {
|
|
161
|
+
[Name in keyof L['targets']]: L['targets'][Name] extends {
|
|
162
|
+
capability: 'versions';
|
|
163
|
+
} ? Name : never;
|
|
164
|
+
}[keyof L['targets']];
|
|
165
|
+
/** What `fields: ({ defaults }) => ...` is handed: the id and timestamps, and the status field of a kayan that keeps drafts. */
|
|
166
|
+
type FieldDefaults<Kept> = typeof defaultFields & (Kept extends {
|
|
167
|
+
readonly drafts: true | object;
|
|
168
|
+
} ? typeof draftFields : unknown);
|
|
169
|
+
/** What `operations: ({ defaults }) => ...` is handed: the shipped operations, and the version ones of a kayan that keeps versions. */
|
|
170
|
+
type OperationDefaults<Kept> = typeof defaultOperations & ([Kept] extends [undefined] ? unknown : typeof versionOperations);
|
|
128
171
|
type Paths<Fields> = Extract<keyof Fields, string> | {
|
|
129
172
|
[Name in Extract<keyof Fields, string>]: Fields[Name] extends {
|
|
130
173
|
relationTo: () => {
|
|
@@ -160,7 +203,7 @@ type CheckedRows<AccessDef, Fields, L extends Language> = AccessDef extends {
|
|
|
160
203
|
};
|
|
161
204
|
} : unknown;
|
|
162
205
|
/** Bound to one language, so a kayan is checked against that language's kinds. */
|
|
163
|
-
export declare const defineKayan: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> = Record<never, never>, const AccessDef extends ({
|
|
206
|
+
export declare const defineKayan: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> | AnyFunction = Record<never, never>, const AccessDef extends ({
|
|
164
207
|
create?: Access;
|
|
165
208
|
read?: Access;
|
|
166
209
|
update?: Access;
|
|
@@ -176,12 +219,16 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
|
|
|
176
219
|
read?: Access;
|
|
177
220
|
update?: Access;
|
|
178
221
|
delete?: Access;
|
|
179
|
-
}, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const Auth extends KayanAuthOf<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined
|
|
222
|
+
}, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const Auth extends KayanAuthOf<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined, const Kept extends KayanVersions | undefined = undefined, const R = Record<never, never>>(kayan: {
|
|
180
223
|
name: Name;
|
|
181
224
|
/** The provider that authenticates this kayan: `betterAuth({ server })`, `local({ secret })`, or a strategy. */
|
|
182
225
|
auth?: Auth;
|
|
183
|
-
/**
|
|
184
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The fields, in column order: `{ ...defaultFields, title }`, or `({ defaults }) => ({ ...defaults, title })`.
|
|
228
|
+
* TypeScript types a callback beside them that reads the fields, such as an operation's `input`
|
|
229
|
+
* over the derived shapes, before it reads what the function returns, so such a kayan writes them as an object.
|
|
230
|
+
*/
|
|
231
|
+
fields: OverDefaults<CheckedFields<L, Fields>, FieldDefaults<Kept>>;
|
|
185
232
|
/** This kayan's own copy, nested by the parts of each key. A key a field's own `label` also claims is a collision. */
|
|
186
233
|
messages?: Messages & { [Key in keyof Messages]: StrayMessages<Messages[Key], LocalesOf<L>>; };
|
|
187
234
|
/** Where `mithaq generate messages` writes this kayan's file, if not the default. */
|
|
@@ -189,14 +236,14 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
|
|
|
189
236
|
/** What each target of this build says about this kayan, by the target's name: `{ drizzle: pgTable(...) }`, `targets.<screen>`, `targets.service`. */
|
|
190
237
|
targets?: KayanTargetsOf<L, Fields, {
|
|
191
238
|
service?: {
|
|
192
|
-
/** Every operation this kayan answers, and nothing else: `{ ...defaultOperations, archive }`. */
|
|
193
|
-
operations?:
|
|
239
|
+
/** Every operation this kayan answers, and nothing else: `{ ...defaultOperations, archive }`, or `({ defaults }) => ({ ...defaults, archive })`. */
|
|
240
|
+
operations?: DeclaredOperations<L, Fields, O, R, OperationDefaults<Kept>>;
|
|
194
241
|
/** The store this kayan is written through. One of the language's targets. */
|
|
195
242
|
store?: StoreName;
|
|
196
243
|
/** The target a write is validated against. One of the language's targets. */
|
|
197
244
|
schema?: SchemaName;
|
|
198
245
|
};
|
|
199
|
-
}
|
|
246
|
+
}> & { [Name_1 in VersionsKey<L>]?: Kept; };
|
|
200
247
|
/** This kayan's own open bag, for the project's own code. Nothing in mithaq reads it, and it stays on the server. */
|
|
201
248
|
custom?: Record<string, unknown>;
|
|
202
249
|
access?: AccessDef & CheckedRows<AccessDef, Fields, L>;
|
|
@@ -221,7 +268,7 @@ export declare const defineKayan: <const L extends Language>(language: L) => <co
|
|
|
221
268
|
out?: string;
|
|
222
269
|
targets: KayanTargetsOf<L, Fields, {
|
|
223
270
|
service: {
|
|
224
|
-
operations: NoInfer<O
|
|
271
|
+
operations: NoInfer<OperationsOf<O, R>>;
|
|
225
272
|
} & NamedStore<StoreName> & NamedSchema<SchemaName>;
|
|
226
273
|
}>;
|
|
227
274
|
custom?: Record<string, unknown>;
|
|
@@ -242,10 +289,10 @@ type GlobalAccess = {
|
|
|
242
289
|
* Declares a global, a kayan of one row. It spreads `defaultGlobalFields` and runs
|
|
243
290
|
* `defaultGlobalOperations`; a tenant field makes it one row per tenant.
|
|
244
291
|
*/
|
|
245
|
-
export declare const defineGlobal: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> = Record<never, never>, const AccessDef extends GlobalAccess = Record<never, never>, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined
|
|
292
|
+
export declare const defineGlobal: <const L extends Language>(language: L) => <const Name extends string, const Fields extends Record<string, FieldOf<L>>, const Messages extends MessageTree<LocalesOf<L>> = Record<never, never>, const O extends Record<string, OperationOf<L, Fields, never>> | AnyFunction = Record<never, never>, const AccessDef extends GlobalAccess = Record<never, never>, const StoreName extends StoreKeys<L['targets']> | undefined = undefined, const SchemaName extends SchemaKeys<L['targets']> | undefined = undefined, const DbName extends string | undefined = undefined, const R = Record<never, never>>(global: {
|
|
246
293
|
name: Name;
|
|
247
|
-
/**
|
|
248
|
-
fields:
|
|
294
|
+
/** The fields, in column order: `{ ...defaultFields, ...defaultGlobalFields, siteTitle }`, or `({ defaults }) => ({ ...defaults, siteTitle })`. */
|
|
295
|
+
fields: OverDefaults<CheckedFields<L, Fields>, typeof defaultFields & typeof defaultGlobalFields>;
|
|
249
296
|
/** This global's own copy, nested by the parts of each key, as on any kayan. */
|
|
250
297
|
messages?: Messages & { [Key in keyof Messages]: StrayMessages<Messages[Key], LocalesOf<L>>; };
|
|
251
298
|
/** Where `mithaq generate messages` writes this global's file, if not the default. */
|
|
@@ -253,8 +300,8 @@ export declare const defineGlobal: <const L extends Language>(language: L) => <c
|
|
|
253
300
|
/** What each target says about this global, exactly as on any kayan. */
|
|
254
301
|
targets?: KayanTargetsOf<L, Fields, {
|
|
255
302
|
service?: {
|
|
256
|
-
/** The two operations a global runs, `defaultGlobalOperations` or a list of the same names
|
|
257
|
-
operations?: O
|
|
303
|
+
/** The two operations a global runs, `defaultGlobalOperations` or a list of the same names, or `({ defaults }) => defaults`. */
|
|
304
|
+
operations?: DeclaredOperations<L, Fields, O, R, typeof defaultGlobalOperations>;
|
|
258
305
|
/** The store this global is written through. One of the language's targets. */
|
|
259
306
|
store?: StoreName;
|
|
260
307
|
/** The target a write is validated against. One of the language's targets. */
|
|
@@ -279,7 +326,7 @@ export declare const defineGlobal: <const L extends Language>(language: L) => <c
|
|
|
279
326
|
out?: string;
|
|
280
327
|
targets: KayanTargetsOf<L, Fields, {
|
|
281
328
|
service: {
|
|
282
|
-
operations: NoInfer<O
|
|
329
|
+
operations: NoInfer<OperationsOf<O, R>>;
|
|
283
330
|
} & NamedStore<StoreName> & NamedSchema<SchemaName>;
|
|
284
331
|
}>;
|
|
285
332
|
custom?: Record<string, unknown>;
|
package/dist/mithaq/define.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { applyRows, rowFields, rowsOf } from './rows.js';
|
|
2
|
-
import { languageRows, serviceTargetOf, tenantDefaults } from './language.js';
|
|
3
|
-
import { resolveOperations } from '../asas/runner/steps.js';
|
|
2
|
+
import { isVersions, languageRows, serviceTargetOf, tenantDefaults } from './language.js';
|
|
3
|
+
import { defaultGlobalOperations, defaultOperations, resolveOperations } from '../asas/runner/steps.js';
|
|
4
|
+
import { defaultFields, defaultGlobalFields } from '../base/fields.js';
|
|
5
|
+
import { draftFields, versionOperations } from '../versions/versions.js';
|
|
6
|
+
const overDefaults = (declared, defaults) => typeof declared === 'function' ? declared({ defaults }) : declared;
|
|
4
7
|
/** Bound to one language, so a kayan is checked against that language's kinds. */
|
|
5
8
|
export const defineKayan = (language) => (kayan) => {
|
|
6
|
-
|
|
7
|
-
const
|
|
9
|
+
const versioning = Object.keys(language.targets).find((name) => isVersions(language.targets[name]));
|
|
10
|
+
const kept = versioning === undefined ? undefined : kayan.targets?.[versioning];
|
|
11
|
+
const declaredFields = overDefaults(kayan.fields, { ...defaultFields, ...(kept?.drafts && draftFields) });
|
|
12
|
+
// The key order is the column order but for the timestamps, which a new table puts last: nothing is merged in.
|
|
13
|
+
const fields = Object.fromEntries(Object.entries(declaredFields).map(([name, field]) => [name, tenantDefaults(language, field)]));
|
|
8
14
|
// A tenant-owned kayan inherits the language's rows; one opts out on its access.
|
|
9
15
|
const declared = kayan.access;
|
|
10
16
|
const owned = Object.values(fields).some((field) => field.targets?.tenant !== undefined);
|
|
@@ -22,7 +28,8 @@ export const defineKayan = (language) => (kayan) => {
|
|
|
22
28
|
}
|
|
23
29
|
// A steps function runs once, here, so the kayan carries data a tool reads without calling anything.
|
|
24
30
|
const own = kayan.targets?.service;
|
|
25
|
-
const
|
|
31
|
+
const declaredOperations = overDefaults(own?.operations, { ...defaultOperations, ...(kept && versionOperations) });
|
|
32
|
+
const operations = resolveOperations(declaredOperations, serviceTargetOf(language));
|
|
26
33
|
return {
|
|
27
34
|
...kayan,
|
|
28
35
|
...(compiled ? { access: applyRows(compiled, declared) } : {}),
|
|
@@ -36,8 +43,15 @@ export const defineKayan = (language) => (kayan) => {
|
|
|
36
43
|
*/
|
|
37
44
|
export const defineGlobal = (language) => {
|
|
38
45
|
const kayan = defineKayan(language);
|
|
39
|
-
return (global) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
return (global) => {
|
|
47
|
+
const service = global.targets?.service;
|
|
48
|
+
const declared = {
|
|
49
|
+
...global,
|
|
50
|
+
fields: overDefaults(global.fields, { ...defaultFields, ...defaultGlobalFields }),
|
|
51
|
+
targets: { ...global.targets, service: { ...service, operations: overDefaults(service?.operations, defaultGlobalOperations) } },
|
|
52
|
+
};
|
|
53
|
+
// `defineKayan`'s own access object, untouched: a rebuilt one would drop the rows
|
|
54
|
+
// marker, and the permission check would stand aside for every global operation.
|
|
55
|
+
return ({ ...kayan(declared), single: true });
|
|
56
|
+
};
|
|
43
57
|
};
|
|
@@ -8,9 +8,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
10
|
import { Refusal } from './refusal.js';
|
|
11
|
+
import { flattenMessageFile, nestMessageFile } from './message-file.js';
|
|
11
12
|
const fileOf = (name, out, directory, root) => out ? new URL(out, root) : new URL(`${name}.json`, directory);
|
|
12
13
|
// What is on disk wins: these files are the project's to translate, so a run adds a
|
|
13
|
-
// missing key and
|
|
14
|
+
// missing key and keeps every translation when rewriting the file shape.
|
|
14
15
|
const merge = (copy, current, locales) => {
|
|
15
16
|
if (typeof copy === 'function')
|
|
16
17
|
return null;
|
|
@@ -75,7 +76,7 @@ export const generateMessages = async ({ registry, directory, check = false, roo
|
|
|
75
76
|
let floor = {};
|
|
76
77
|
for (const [at, scope] of registry.scopes.entries()) {
|
|
77
78
|
const file = files[at];
|
|
78
|
-
const current = committed[at];
|
|
79
|
+
const current = flattenMessageFile({ file: committed[at], locales: registry.locales });
|
|
79
80
|
const wanted = { ...current };
|
|
80
81
|
for (const key of Object.keys(current)) {
|
|
81
82
|
if (!Object.hasOwn(scope.keys, key))
|
|
@@ -108,8 +109,8 @@ export const generateMessages = async ({ registry, directory, check = false, roo
|
|
|
108
109
|
report.missing.push(`${scope.name}.${key} has no "${locale}".`);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
|
-
const source = `${JSON.stringify(
|
|
112
|
-
if (source === `${JSON.stringify(
|
|
112
|
+
const source = `${JSON.stringify(nestMessageFile(wanted), null, 2)}\n`;
|
|
113
|
+
if (source === `${JSON.stringify(committed[at], null, 2)}\n`)
|
|
113
114
|
continue;
|
|
114
115
|
report.changed.push(scope.out ?? `${scope.name}.json`);
|
|
115
116
|
if (check)
|
|
@@ -7,6 +7,7 @@ import type { VersionsTarget } from '../versions/index.ts';
|
|
|
7
7
|
import type { StorageTarget } from '../storage/storage.ts';
|
|
8
8
|
import { buildAsas, type HttpPlug } from '../asas/build.ts';
|
|
9
9
|
import type { AuthConfig } from '../asas/build.ts';
|
|
10
|
+
import type { DeclarationStore } from '../asas/runner/write.ts';
|
|
10
11
|
import type { AuditHandler, Derives, DerivedShapes } from '../asas/runner/steps.ts';
|
|
11
12
|
import type { Where } from './where.ts';
|
|
12
13
|
import { defineGlobal, defineKayan } from './define.ts';
|
|
@@ -100,12 +101,16 @@ export type TenantTarget<RelationTo extends string = string> = {
|
|
|
100
101
|
readonly capability: 'tenant';
|
|
101
102
|
readonly relationTo: RelationTo;
|
|
102
103
|
readonly onDelete: 'restrict';
|
|
104
|
+
/** A new row on a tenant-owned kayan takes the active organization, through an operation or a step's insert/upsert; naming another organization is refused. */
|
|
105
|
+
readonly newRows: 'active';
|
|
106
|
+
/** How many organizations the project holds; in `'one'` a `connect()` over more refuses to start. */
|
|
107
|
+
readonly organizations: 'one' | 'many';
|
|
103
108
|
readonly field: TenantField<RelationTo>;
|
|
104
109
|
/**
|
|
105
|
-
* Whether this actor is
|
|
110
|
+
* Whether this actor is backoffice staff in the trusted backoffice app, who may read every
|
|
106
111
|
* tenant's rows with `tenant: 'all'` and never write through it. Absent, no one may.
|
|
107
112
|
*/
|
|
108
|
-
readonly
|
|
113
|
+
readonly backoffice?: (args: {
|
|
109
114
|
user: unknown;
|
|
110
115
|
}) => boolean | Promise<boolean>;
|
|
111
116
|
};
|
|
@@ -136,6 +141,15 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
|
|
|
136
141
|
kayan: string;
|
|
137
142
|
data: Record<string, unknown>;
|
|
138
143
|
}): Promise<Row>;
|
|
144
|
+
/** Insert or update by the declared unique index `on` names, or by `on: ['id']` without a declared unique index, with key values in `data`. An increment inserts its amount or adds it to the existing value. Returns the row with its existing identity kept. */
|
|
145
|
+
upsert(args: {
|
|
146
|
+
tx: Tx;
|
|
147
|
+
kayan: string;
|
|
148
|
+
on: readonly string[];
|
|
149
|
+
data: Record<string, unknown>;
|
|
150
|
+
where?: Where;
|
|
151
|
+
}): Promise<Row>;
|
|
152
|
+
/** `{ field: { $inc: n } }` adds a safe integer in the database to an integer field, alongside plain values. Store writes skip validation; enforce quantities that must not become negative with a database CHECK. Returns the changed row. */
|
|
139
153
|
update(args: {
|
|
140
154
|
tx: Tx;
|
|
141
155
|
kayan: string;
|
|
@@ -154,8 +168,14 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
|
|
|
154
168
|
kayan: string;
|
|
155
169
|
args: {
|
|
156
170
|
limit: number;
|
|
157
|
-
/**
|
|
158
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Hold returned rows until this transaction ends. `update` excludes every competing lock;
|
|
173
|
+
* `noKeyUpdate` keeps their values unchanged by others while allowing `keyShare` readers;
|
|
174
|
+
* `share` keeps them as read while allowing other `share` and `keyShare` readers;
|
|
175
|
+
* `keyShare` keeps their identity and unique keys, allowing other values to change.
|
|
176
|
+
* Every mode requires a transaction; a lock without one must throw.
|
|
177
|
+
*/
|
|
178
|
+
lock?: 'update' | 'noKeyUpdate' | 'share' | 'keyShare';
|
|
159
179
|
page: number;
|
|
160
180
|
sort: {
|
|
161
181
|
field: string;
|
|
@@ -168,6 +188,13 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
|
|
|
168
188
|
docs: Row[];
|
|
169
189
|
totalDocs: number;
|
|
170
190
|
}>;
|
|
191
|
+
/** Exact total of an integer, number or decimal field, as a decimal string so no value passes through a float; empty selections return '0'. */
|
|
192
|
+
sum(args: {
|
|
193
|
+
tx?: Tx;
|
|
194
|
+
kayan: string;
|
|
195
|
+
field: string;
|
|
196
|
+
where?: Where;
|
|
197
|
+
}): Promise<string>;
|
|
171
198
|
/** Counts matching rows without loading them. Without it, `find` with a limit of `0` is asked. */
|
|
172
199
|
count?(args: {
|
|
173
200
|
kayan: string;
|
|
@@ -178,6 +205,18 @@ export type Store<Kinds extends Record<string, Kind> = Record<string, Kind>, Rul
|
|
|
178
205
|
}): Promise<number>;
|
|
179
206
|
destroy(): Promise<void>;
|
|
180
207
|
};
|
|
208
|
+
/** The runner keeps bound string-name calls behind the declaration boundary: access and field masks are skipped, tenant scope is not. Upsert fills the tenant from the active organization and refuses another value. Never expose privileged rows or fields through step output. */
|
|
209
|
+
export type RawStepStore = Pick<Store, 'find' | 'insert' | 'remove' | 'update' | 'upsert' | 'sum'> & {
|
|
210
|
+
/** Holds existing ids in ascending order until the transaction ends, deduplicated; any missing or foreign id is a 404. Requires a transaction. */
|
|
211
|
+
hold(args: {
|
|
212
|
+
tx: unknown;
|
|
213
|
+
kayan: string;
|
|
214
|
+
ids: readonly unknown[];
|
|
215
|
+
mode: NonNullable<Parameters<Store['find']>[0]['args']['lock']>;
|
|
216
|
+
}): Promise<void>;
|
|
217
|
+
};
|
|
218
|
+
/** A step names a kayan by its declaration; fields and values follow that declaration, as with tools.of. */
|
|
219
|
+
export type StepStore = DeclarationStore;
|
|
181
220
|
/** A target that holds rows. Takes `undefined`, so a caller can name the bad target in its own throw. */
|
|
182
221
|
export declare const isStore: (target: RuntimeTarget | undefined) => target is RuntimeStore;
|
|
183
222
|
/** A target that says who may do what. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A translated leaf, an opaque resolver result, or a function's unverifiable marker. */
|
|
2
|
+
export type MessageEntry = Record<string, string> | string | null;
|
|
3
|
+
type Branch = {
|
|
4
|
+
[key: string]: MessageEntry | Branch;
|
|
5
|
+
};
|
|
6
|
+
/** Reads both release formats, keeping a parent's own value separate from its children. */
|
|
7
|
+
export declare const flattenMessageFile: ({ file, locales }: {
|
|
8
|
+
file: Record<string, unknown>;
|
|
9
|
+
locales: readonly string[];
|
|
10
|
+
}) => Record<string, MessageEntry>;
|
|
11
|
+
/** A value gets `$value` only when another key needs to pass through it. */
|
|
12
|
+
export declare const nestMessageFile: (entries: Record<string, MessageEntry>) => Branch;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const branch = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
2
|
+
/** Reads both release formats, keeping a parent's own value separate from its children. */
|
|
3
|
+
export const flattenMessageFile = ({ file, locales }) => {
|
|
4
|
+
const entries = [];
|
|
5
|
+
const walk = (node, parts) => {
|
|
6
|
+
for (const [name, value] of Object.entries(node)) {
|
|
7
|
+
const path = [...parts, name];
|
|
8
|
+
if (name === '$value')
|
|
9
|
+
entries.push([parts.join('.'), value]);
|
|
10
|
+
else if (branch(value) && !(Object.values(value).every((one) => typeof one === 'string') && (!Object.keys(value).length || Object.keys(value).some((key) => locales.includes(key)))))
|
|
11
|
+
walk(value, path);
|
|
12
|
+
else
|
|
13
|
+
entries.push([path.join('.'), value]);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
walk(file, []);
|
|
17
|
+
return Object.fromEntries(entries);
|
|
18
|
+
};
|
|
19
|
+
/** A value gets `$value` only when another key needs to pass through it. */
|
|
20
|
+
export const nestMessageFile = (entries) => {
|
|
21
|
+
const result = Object.create(null);
|
|
22
|
+
const keys = Object.keys(entries).sort();
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
const parts = [...key.split('.'), ...(keys.some((other) => other.startsWith(`${key}.`)) ? ['$value'] : [])];
|
|
25
|
+
const last = parts.pop();
|
|
26
|
+
parts.reduce((node, part) => (node[part] ??= Object.create(null)), result)[last] = entries[key];
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
};
|
package/dist/mithaq/messages.js
CHANGED
|
@@ -42,6 +42,11 @@ export const messagesOf = ({ language, kayanList, resolver, }) => {
|
|
|
42
42
|
continue;
|
|
43
43
|
claim(name, `field "${name}" label`, field.label);
|
|
44
44
|
}
|
|
45
|
+
// Screens share this fallback key, so the first description is claimed once.
|
|
46
|
+
const describe = (screen) => kayan.targets?.[screen]?.description;
|
|
47
|
+
const described = Object.keys(language.targets).find((screen) => language.targets[screen].capability === 'screen' && describe(screen));
|
|
48
|
+
if (described)
|
|
49
|
+
claim('page.description', `screen "${described}" description`, describe(described));
|
|
45
50
|
// Claimed once per field however many screens ask for it.
|
|
46
51
|
for (const [name, operation] of Object.entries(kayan.operations)) {
|
|
47
52
|
const fields = Object.entries(operation.targets ?? {}).flatMap(([screen, block]) => language.targets[screen]?.capability === 'screen' ? Object.entries(block?.fields ?? {}) : []);
|
package/dist/mithaq/resolve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { admissions, rowsOf, rowsOfField } from './rows.js';
|
|
2
2
|
import { isRef, refuseLiteral } from './user.js';
|
|
3
|
-
import {
|
|
3
|
+
import { jsonSchemaCheck } from '../base/json-schema.js';
|
|
4
|
+
import { bulkSeals, defaultGlobalOperations, holdOutput, readSeals, resolveOperations, sealOf, foreignCopyOf, storeSeals, versionSeals, } from '../asas/runner/steps.js';
|
|
4
5
|
import { kayanServiceKeys, unsortable } from './types.js';
|
|
5
6
|
import { compareDecimals } from '../base/decimal.js';
|
|
6
7
|
import { isAccess, isScreen, isService, isStorage, isStore, isTenant, isVersions, serviceTargetOf, tenantDefaults } from './language.js';
|
|
@@ -17,7 +18,7 @@ export const versionsTableOf = (kayan) => `_${kayan.table}_versions`;
|
|
|
17
18
|
/** The default indexes a `unique` rule makes, which a store may build as the column's own constraint. */
|
|
18
19
|
export const uniqueRuleIndexes = new WeakSet();
|
|
19
20
|
export const isPlainIdentifier = (value) => value.length <= 63 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
20
|
-
const fixed = new Set(['access', 'column', 'custom', 'dbName', 'description', 'kayan', 'kind', 'kindTargets', 'label', 'locales', 'name', 'now', 'operators', 'options', 'placeholder', 'prefix', 'schemaTarget', 'sortable', 'suffix', 'target', 'targets', 'rules']);
|
|
21
|
+
const fixed = new Set(['access', 'column', 'custom', 'dbName', 'description', 'jsonSchema', 'kayan', 'kind', 'kindTargets', 'label', 'locales', 'maxBytes', 'name', 'now', 'operators', 'options', 'placeholder', 'prefix', 'schemaTarget', 'sortable', 'suffix', 'target', 'targets', 'rules']);
|
|
21
22
|
// Every list holds exactly one store or managed read step, and none of the three
|
|
22
23
|
// the runner owns; the effect is read off that one step, so it is never declared.
|
|
23
24
|
const effects = {
|
|
@@ -102,6 +103,11 @@ const sealed = (kayan, operation, declaration, service) => {
|
|
|
102
103
|
if (list.some((step) => typeof step !== 'function')) {
|
|
103
104
|
throw new Error(`${where} has a step that is not a function.`);
|
|
104
105
|
}
|
|
106
|
+
// Its errors, actors and secret columns are unknown here too, so two copies are refused, never bridged.
|
|
107
|
+
const foreign = list.filter((step) => !sealOf(step)).map(foreignCopyOf).find(Boolean);
|
|
108
|
+
if (foreign) {
|
|
109
|
+
throw new Error(`${where} holds a step another copy of mithaq sealed, loaded from ${foreign}, beside this one at ${import.meta.url}. Install one copy: name mithaq by the same spec, with the same peers, in every package that imports it, so the package manager links it once.`);
|
|
110
|
+
}
|
|
105
111
|
const held = list.map(sealOf);
|
|
106
112
|
for (const seal of held) {
|
|
107
113
|
const owned = seal && runnerSteps[seal];
|
|
@@ -134,7 +140,7 @@ const sealed = (kayan, operation, declaration, service) => {
|
|
|
134
140
|
return resolved({ effect });
|
|
135
141
|
};
|
|
136
142
|
// `onDelete` is a rule too, so on a relationship it is still checked against every target.
|
|
137
|
-
const relationshipKeys = new Set(['hasMany', 'mode', 'relationTo']);
|
|
143
|
+
const relationshipKeys = new Set(['filterOptions', 'hasMany', 'mode', 'relationTo']);
|
|
138
144
|
const joinKeys = new Set(['defaultLimit', 'defaultSort', 'on', 'relationTo']);
|
|
139
145
|
/** Names that reach the prototype rather than a key an actor carries. */
|
|
140
146
|
const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']);
|
|
@@ -144,7 +150,7 @@ const targetOf = (who, named, language, role) => {
|
|
|
144
150
|
const target = language.targets[named];
|
|
145
151
|
if (!target || isStore(target) !== (role === 'store')) {
|
|
146
152
|
throw new Error(role === 'store'
|
|
147
|
-
? `${who} names store "${named}", which is not a store: a store target declares capability "store" and has init, connect, transaction, insert, update, remove, find and destroy beside kinds and rules.`
|
|
153
|
+
? `${who} names store "${named}", which is not a store: a store target declares capability "store" and has init, connect, transaction, insert, update, upsert, remove, find, sum and destroy beside kinds and rules.`
|
|
148
154
|
: `${who} names schema "${named}", which is not a schema target: a schema target has kinds and rules, and does not declare capability "store".`);
|
|
149
155
|
}
|
|
150
156
|
if (role === 'schema' && typeof target.derive !== 'function') {
|
|
@@ -183,7 +189,7 @@ const nameOf = (relationTo) => typeof relationTo === 'function' ? String(relatio
|
|
|
183
189
|
const tenantField = ({ where, field, language }) => {
|
|
184
190
|
const target = language.targets.tenant;
|
|
185
191
|
if (!isTenant(target)) {
|
|
186
|
-
throw new Error(`${where} is bound through targets.tenant, and this language lays down no tenant target. Add tenant: betterAuthTenant({ relationTo, onDelete: 'restrict' }) to its targets, or drop targets.tenant from the field.`);
|
|
192
|
+
throw new Error(`${where} is bound through targets.tenant, and this language lays down no tenant target. Add tenant: betterAuthTenant({ relationTo, onDelete: 'restrict', newRows: 'active', organizations }) to its targets, or drop targets.tenant from the field.`);
|
|
187
193
|
}
|
|
188
194
|
const bound = tenantDefaults(language, field);
|
|
189
195
|
const rules = bound.rules ?? {};
|
|
@@ -212,6 +218,10 @@ const tenantField = ({ where, field, language }) => {
|
|
|
212
218
|
}
|
|
213
219
|
return bound;
|
|
214
220
|
};
|
|
221
|
+
const messageName = (where, name) => {
|
|
222
|
+
if (name.startsWith('$'))
|
|
223
|
+
throw new Error(`${where} "${name}" is reserved: names starting with "$" are reserved for message-file values.`);
|
|
224
|
+
};
|
|
215
225
|
// A key written flat is refused with the nesting spelled out. A node holding both a
|
|
216
226
|
// sentence and a group, or nothing, is refused: either reading would destroy copy.
|
|
217
227
|
const flattenMessages = (where, messages, locales, parts = []) =>
|
|
@@ -219,6 +229,8 @@ const flattenMessages = (where, messages, locales, parts = []) =>
|
|
|
219
229
|
// runs the setter, so the copy would vanish and the prototype be whatever was written.
|
|
220
230
|
Object.fromEntries(Object.entries(messages).flatMap(([key, value]) => {
|
|
221
231
|
const path = [...parts, ...key.split('.')];
|
|
232
|
+
if (key.startsWith('$'))
|
|
233
|
+
throw new Error(`Kayan "${where}" message key "${path.join('.')}" is reserved: names starting with "$" are reserved for message-file values.`);
|
|
222
234
|
if (key.includes('.')) {
|
|
223
235
|
throw new Error(`Kayan "${where}" writes the message key "${path.join('.')}" flat. Nest it under "${key.split('.')[0]}": messages: ${path.map((part) => `{ ${part}: `).join('')}...${' }'.repeat(path.length)}.`);
|
|
224
236
|
}
|
|
@@ -295,12 +307,14 @@ export const resolve = (kayanList, language) => {
|
|
|
295
307
|
// For a table written as plain data, which `kind` does not type. A rule may ship no
|
|
296
308
|
// sentence; every kind refuses the value it is handed.
|
|
297
309
|
for (const [name, kind] of Object.entries(language.kinds)) {
|
|
310
|
+
messageName("The language's kind", name);
|
|
298
311
|
if (!kind.message) {
|
|
299
312
|
throw new Error(`The language's kind "${name}" declares no message, so a value it refuses has no sentence to show.`);
|
|
300
313
|
}
|
|
301
314
|
}
|
|
302
315
|
// A typo would otherwise exclude nothing in silence.
|
|
303
316
|
for (const [name, one] of Object.entries(language.rules)) {
|
|
317
|
+
messageName("The language's rule", name);
|
|
304
318
|
const missing = one.excludes?.find((other) => !Object.hasOwn(language.rules, other));
|
|
305
319
|
if (missing) {
|
|
306
320
|
throw new Error(`The language's rule "${name}" excludes "${missing}", which is not a rule the language names.`);
|
|
@@ -466,6 +480,9 @@ export const resolve = (kayanList, language) => {
|
|
|
466
480
|
if (isTenant(tenantTarget) && tenantTarget.onDelete !== 'restrict') {
|
|
467
481
|
throw new Error(`The tenant target declares onDelete ${JSON.stringify(tenantTarget.onDelete)}, and a tenant takes onDelete: 'restrict' alone.`);
|
|
468
482
|
}
|
|
483
|
+
if (isTenant(tenantTarget) && tenantTarget.newRows !== 'active') {
|
|
484
|
+
throw new Error(`The tenant target declares newRows ${JSON.stringify(tenantTarget.newRows)}, and a tenant takes newRows: 'active' alone.`);
|
|
485
|
+
}
|
|
469
486
|
const byName = new Map(kayanList.map((kayan) => [kayan.name, kayan]));
|
|
470
487
|
const resolved = kayanList.map((kayan) => {
|
|
471
488
|
if (kayan.targets && Object.hasOwn(kayan.targets, 'tenant')) {
|
|
@@ -500,6 +517,9 @@ export const resolve = (kayanList, language) => {
|
|
|
500
517
|
continue;
|
|
501
518
|
throw new Error(`Kayan "${kayan.name}" writes "targets.service.${key}", which is no key of a kayan's service target. A kayan writes ${kayanServiceKeys.join(', ')} there.`);
|
|
502
519
|
}
|
|
520
|
+
if (typeof own.operations === 'function') {
|
|
521
|
+
throw new Error(`Kayan "${kayan.name}" writes its operations as a function, which only defineKayan and defineGlobal run, since they know the defaults it is handed. Declare it with defineKayan, or write its operations as an object: { ...defaultOperations, archive }.`);
|
|
522
|
+
}
|
|
503
523
|
return {
|
|
504
524
|
...kayan,
|
|
505
525
|
// Flattened here, so every reader of a resolved kayan sees the key space a generated file carries.
|
|
@@ -513,6 +533,7 @@ export const resolve = (kayanList, language) => {
|
|
|
513
533
|
// Run again for a kayan written as plain data. `false` would remove nothing from
|
|
514
534
|
// the kayan's own record, so it is refused by name.
|
|
515
535
|
operations: Object.fromEntries(Object.entries(resolveOperations(own.operations, service)).filter(([operation, declaration]) => {
|
|
536
|
+
messageName(`Kayan "${kayan.name}" operation`, operation);
|
|
516
537
|
if (declaration === false) {
|
|
517
538
|
throw new Error(`Kayan "${kayan.name}" operation "${operation}" is false, and false is no operation. Leave "${operation}" out of the object instead: const { ${operation}, ...rest } = defaultOperations.`);
|
|
518
539
|
}
|
|
@@ -533,10 +554,9 @@ export const resolve = (kayanList, language) => {
|
|
|
533
554
|
required: field.rules?.required ?? false,
|
|
534
555
|
// A join holds no column here, so `providedBy: never` keeps the key off every write shape.
|
|
535
556
|
...(field.kind === 'join' && { providedBy: { create: 'never', update: 'never' } }),
|
|
536
|
-
// A
|
|
537
|
-
...(pointsAtRow(field.kind) && field.relationTo &&
|
|
538
|
-
|
|
539
|
-
onDelete: field.rules?.onDelete ?? (field.rules?.required ? 'restrict' : language.kinds[field.kind].onDelete),
|
|
557
|
+
// A required field and a set restrict, since neither has a null to set; any other takes its kind's default.
|
|
558
|
+
...(pointsAtRow(field.kind) && field.relationTo && {
|
|
559
|
+
onDelete: field.rules?.onDelete ?? (field.rules?.required || field.hasMany ? 'restrict' : language.kinds[field.kind].onDelete),
|
|
540
560
|
}),
|
|
541
561
|
},
|
|
542
562
|
// A join builds no column, but takes a name so the one place that prints it prints no blank.
|
|
@@ -593,6 +613,35 @@ export const resolve = (kayanList, language) => {
|
|
|
593
613
|
}
|
|
594
614
|
}
|
|
595
615
|
};
|
|
616
|
+
const jsonSchemaNamed = (where, field) => {
|
|
617
|
+
if (field.kind !== 'json' && field.maxBytes !== undefined)
|
|
618
|
+
throw new Error(`${where} has a maxBytes, and kind "${field.kind}" takes none; only a json field does.`);
|
|
619
|
+
if (field.kind === 'json' && !(Number.isInteger(field.maxBytes) && field.maxBytes > 0)) {
|
|
620
|
+
throw new Error(`${where} is json and names no maxBytes; name maxBytes: defaultJsonMaxBytes, or a whole number of its own.`);
|
|
621
|
+
}
|
|
622
|
+
const { jsonSchema } = field;
|
|
623
|
+
if (jsonSchema === undefined)
|
|
624
|
+
return;
|
|
625
|
+
if (field.kind !== 'json')
|
|
626
|
+
throw new Error(`${where} has a jsonSchema, and kind "${field.kind}" takes none; only a json field does.`);
|
|
627
|
+
const schema = jsonSchema?.schema;
|
|
628
|
+
if (typeof schema !== 'boolean' && (!schema || typeof schema !== 'object' || Array.isArray(schema))) {
|
|
629
|
+
throw new Error(`${where} jsonSchema names no "schema"; a schema is held in the field and never fetched from its "uri".`);
|
|
630
|
+
}
|
|
631
|
+
const { uri, fileMatch } = jsonSchema;
|
|
632
|
+
if ((uri !== undefined && typeof uri !== 'string') || (fileMatch !== undefined && !(Array.isArray(fileMatch) && fileMatch.every((one) => typeof one === 'string')))) {
|
|
633
|
+
throw new Error(`${where} jsonSchema "fileMatch" is a list of strings and "uri" a string.`);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
// Else every create that leaves the field out would be refused for a value it never sent.
|
|
637
|
+
const jsonDefaultNamed = (where, field) => {
|
|
638
|
+
const value = field.rules?.defaultValue;
|
|
639
|
+
if (field.kind !== 'json' || value === undefined || isRef(value))
|
|
640
|
+
return;
|
|
641
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > field.maxBytes || (field.jsonSchema && !jsonSchemaCheck(field)(value))) {
|
|
642
|
+
throw new Error(`${where} defaultValue is refused by its own jsonSchema or maxBytes.`);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
596
645
|
const declaredTargets = (where, wrote, known = targetNames) => {
|
|
597
646
|
for (const name of Object.keys(wrote ?? {})) {
|
|
598
647
|
if (known.has(name))
|
|
@@ -609,6 +658,7 @@ export const resolve = (kayanList, language) => {
|
|
|
609
658
|
targetOf(`Kayan "${kayan.name}"`, kayan.schema, language, 'schema');
|
|
610
659
|
for (const field of Object.values(kayan.fields)) {
|
|
611
660
|
const where = `Kayan "${kayan.name}" field "${field.name}"`;
|
|
661
|
+
messageName(`Kayan "${kayan.name}" field`, field.name);
|
|
612
662
|
declaredTargets(where, field.targets);
|
|
613
663
|
// The field found must be a relationship pointing back here, or the join would read
|
|
614
664
|
// a column that is no pointer back and answer another row's rows.
|
|
@@ -674,10 +724,17 @@ export const resolve = (kayanList, language) => {
|
|
|
674
724
|
if (Object.hasOwn(field, 'mode')) {
|
|
675
725
|
throw new Error(`${where} carries "mode", which a field does not take: a relationship points at one row, and "hasMany: true" makes it hold a set.`);
|
|
676
726
|
}
|
|
727
|
+
// Its delete would fail in the database on the not-null column instead.
|
|
728
|
+
if (!field.hasMany && field.rules?.required && field.rules.onDelete === 'set null') {
|
|
729
|
+
throw new Error(`${where} is required and declares onDelete "set null", and a required column holds no null. Write "restrict" or "cascade".`);
|
|
730
|
+
}
|
|
677
731
|
// A rule that silently builds nothing is what this file exists to prevent.
|
|
678
732
|
// `required` is not one: the schema target reads it off the array.
|
|
679
733
|
if (field.hasMany) {
|
|
680
|
-
|
|
734
|
+
if (field.rules?.onDelete === 'set null') {
|
|
735
|
+
throw new Error(`${where} is a relationship with hasMany and onDelete "set null", and a set's join row holds no null. Write "restrict", which keeps the row while a set names it, or "cascade", which takes it out of every set.`);
|
|
736
|
+
}
|
|
737
|
+
const columnRule = ['defaultValue', 'index', 'unique'].find((rule) => field.rules?.[rule] !== undefined);
|
|
681
738
|
if (columnRule) {
|
|
682
739
|
throw new Error(`${where} is a relationship with hasMany, which the store holds as a join table and not as a column of this kayan, so rule "${columnRule}" would build nothing. Declare it on the kayan this field points at, or drop it.`);
|
|
683
740
|
}
|
|
@@ -694,6 +751,8 @@ export const resolve = (kayanList, language) => {
|
|
|
694
751
|
}
|
|
695
752
|
optionsNamed(where, field.kind, field);
|
|
696
753
|
affixesNamed(where, field.kind, field);
|
|
754
|
+
jsonSchemaNamed(where, field);
|
|
755
|
+
jsonDefaultNamed(where, field);
|
|
697
756
|
for (const [screen, block] of Object.entries(field.targets ?? {})) {
|
|
698
757
|
if (!isScreen(language.targets[screen]))
|
|
699
758
|
continue;
|
|
@@ -849,6 +908,7 @@ export const resolve = (kayanList, language) => {
|
|
|
849
908
|
const inputs = Object.entries(declaration.targets ?? {}).flatMap(([screen, block]) => isScreen(language.targets[screen]) ? Object.entries(block.fields ?? {}) : []);
|
|
850
909
|
for (const [name, field] of inputs) {
|
|
851
910
|
const where = `Kayan "${kayan.name}" operation "${operation}" field "${name}"`;
|
|
911
|
+
messageName(`Kayan "${kayan.name}" operation "${operation}" field`, name);
|
|
852
912
|
declaredTargets(where, field.targets);
|
|
853
913
|
optionsNamed(where, field.kind, field);
|
|
854
914
|
affixesNamed(where, field.kind, field);
|