@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.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +962 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1056 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -1,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
3
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
- import { join, relative } from 'node:path';
4
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { promisify } from 'node:util';
7
7
  import { isIdentifierName } from '@babel/helper-validator-identifier';
@@ -63,7 +63,47 @@ export type ScreenClient = Client<Pages>
63
63
  };
64
64
  // One literal, lazy `import.meta.glob` per package over exactly the files the
65
65
  // references name: Vite resolves a glob at build and takes no variable.
66
- const globOf = ({ screen, client, root, app, appName, linked }) => {
66
+ const globOf = ({ screen, client, root, app, appName, from }) => {
67
+ // Each base is written from the screen's own folder into the project, the
68
+ // folder holding both the language and the app: the workspace root of a
69
+ // monorepo, the package itself otherwise. `files.ts` is committed, so a base
70
+ // never names a folder outside it, which differs from machine to machine.
71
+ const realApp = realpathSync(fileURLToPath(app));
72
+ const here = join(realApp, relative(fileURLToPath(app), fileURLToPath(from)));
73
+ const ours = realpathSync(fileURLToPath(root)).split(sep);
74
+ const theirs = realApp.split(sep);
75
+ let shared = 0;
76
+ while (shared < ours.length && ours[shared] === theirs[shared])
77
+ shared++;
78
+ const project = ours.slice(0, shared).join(sep) || sep;
79
+ const inProject = (folder) => !isAbsolute(relative(project, folder)) && relative(project, folder).split(sep)[0] !== '..';
80
+ const baseOf = (folder) => {
81
+ if (!inProject(folder))
82
+ throw new Refusal(`The ${screen} screen's files are read from ${folder}, outside the project at ${project}.`);
83
+ return `${relative(here, folder).replaceAll('\\', '/')}/`;
84
+ };
85
+ const linkFrom = ({ start, name }) => {
86
+ for (let folder = start;; folder = dirname(folder)) {
87
+ const link = join(folder, 'node_modules', name);
88
+ if (existsSync(link))
89
+ return link;
90
+ if (folder === project || dirname(folder) === folder)
91
+ return undefined;
92
+ }
93
+ };
94
+ // A workspace package is read where it is, which npm, pnpm, yarn and Bun
95
+ // agree on. Any other is read through its `node_modules` link, because the
96
+ // folder behind it names a version and a hash, or lies outside the project.
97
+ // An app that depends on the asas alone links no feature package under an
98
+ // isolated install (pnpm, Bun), so the asas, which depends on each, is asked next.
99
+ const installed = (name) => {
100
+ const link = linkFrom({ start: realApp, name }) ?? linkFrom({ start: ours.join(sep), name });
101
+ if (!link)
102
+ throw new Refusal(`The ${screen} screen names files of ${name}, which no node_modules in the project holds. Install it in the app.`);
103
+ const real = realpathSync(link);
104
+ return baseOf(inProject(real) && !relative(project, real).split(sep).includes('node_modules') ? real : link);
105
+ };
106
+ const linked = app.href === root.href ? '/' : baseOf(ours.join(sep));
67
107
  const byPackage = new Map();
68
108
  for (const key of new Set(referencesIn(client).map((reference) => reference.slice(0, reference.indexOf('#'))))) {
69
109
  const name = key.startsWith('./') ? '' : key.slice(0, key.indexOf(':'));
@@ -92,7 +132,7 @@ export const files = ${own}
92
132
  // Every file the \`${screen}\` screen's references name, each loaded when a page draws it, keyed as the reference prints it.
93
133
  const named = (name: string, files: Record<string, () => Promise<unknown>>) => Object.fromEntries(Object.entries(files).map(([file, load]) => [\`\${name}:\${file}\`, load]))
94
134
  export const files = {
95
- ${bare.length ? ` ...${own},\n` : ''}${named.map((name) => ` ...named(${sourceString(name)}, ${glob(byPackage.get(name), name === appName ? '/' : `/node_modules/${name}/`)}),\n`).join('')}}
135
+ ${bare.length ? ` ...${own},\n` : ''}${named.map((name) => ` ...named(${sourceString(name)}, ${glob(byPackage.get(name), name === appName ? '/' : installed(name))}),\n`).join('')}}
96
136
  `;
97
137
  };
98
138
  const routesOf = (screen, client, prefix) => {
@@ -153,14 +193,13 @@ export const generateScreens = async ({ root, screens, into, locales = [], check
153
193
  const prefix = decodeURIComponent(into.pathname.slice(routesAt + '/routes'.length));
154
194
  // The app is the package holding `into`, and Vite's root is its folder.
155
195
  const app = packageOf(into) ?? root;
156
- const linked = app.href === root.href ? '/' : `/node_modules/${JSON.parse(await readFile(new URL('package.json', root), 'utf8')).name}/`;
157
196
  const appName = JSON.parse(await readFile(new URL('package.json', app), 'utf8').catch(() => '{}')).name;
158
197
  const wanted = Object.entries(screens).flatMap(([key, client]) => {
159
198
  const from = new URL(`${key}/`, into);
160
199
  return [
161
200
  [new URL('screen.json', from), `${JSON.stringify(client, null, 2)}\n`],
162
201
  [new URL('types.ts', from), typesOf(client, locales)],
163
- [new URL('files.ts', from), globOf({ screen: key, client, root, app, appName, linked })],
202
+ [new URL('files.ts', from), globOf({ screen: key, client, root, app, appName, from })],
164
203
  ...routesOf(key, client, prefix).map(([file, source]) => [new URL(file, into), source]),
165
204
  ];
166
205
  });
@@ -209,6 +248,9 @@ export const run = async (project, check) => {
209
248
  if (folder(at))
210
249
  byFolder.set(at, {});
211
250
  for (const [name, client] of Object.entries(screens)) {
251
+ // `false` is a project saying no app of its own serves this screen, so nothing is written for it.
252
+ if (!folder(into) && into[name] === false)
253
+ continue;
212
254
  const at = folder(into) ?? folder(into[name]);
213
255
  if (!at)
214
256
  throw new Refusal(`generate.screens names no folder for the screen ${name}. Name one: \`screens: { ${name}: 'src/routes/(screens)/' }\`.`);
@@ -53,6 +53,10 @@ export declare const screenKinds: {
53
53
  cell: "relationship";
54
54
  control: "relationship";
55
55
  };
56
+ readonly json: {
57
+ cell: "json";
58
+ control: "json";
59
+ };
56
60
  readonly join: {
57
61
  cell: "join";
58
62
  control: "join";
@@ -216,6 +220,12 @@ export type ScreenList<Locale extends string = string, Ref extends string | Ui =
216
220
  readonly sort?: FieldName | `-${FieldName}`;
217
221
  /** The page size a list opens with, which the URL's own `limit` overrides. */
218
222
  readonly limit?: number;
223
+ /** The list's tabs, each a `where` it holds every page to; the first is the one it opens on. */
224
+ readonly tabs?: readonly {
225
+ readonly tab: string;
226
+ readonly label: Localized<Locale>;
227
+ readonly where?: Where;
228
+ }[];
219
229
  };
220
230
  /** A button that runs one of the kayan's operations, wherever it sits: `button` names the operation. */
221
231
  export type ScreenButton<Locale extends string = string, Ref extends string | Ui = string | Ui> = {
@@ -235,7 +245,7 @@ export type ScreenButton<Locale extends string = string, Ref extends string | Ui
235
245
  export type ScreenPage<Locale extends string = string, Ref extends string | Ui = string | Ui, FieldName extends string = string> = {
236
246
  readonly path: string;
237
247
  readonly page: ScreenFrame<Locale, Ref, FieldName>;
238
- /** `create` and `edit` only: the record opens over the list, in one of shadcn's three. */
248
+ /** `create` and `edit` only: the record opens over the list, in shadcn's dialog or sheet, or a full-page drawer as Payload's document drawer. */
239
249
  readonly mode?: 'dialog' | 'drawer' | 'sheet';
240
250
  readonly component?: never;
241
251
  } | {
@@ -274,6 +284,8 @@ type FieldData = {
274
284
  on?: string;
275
285
  /** Relationship only: whether this field holds a set of rows rather than one. */
276
286
  hasMany?: true;
287
+ /** Relationship only: whether its picker reads `for` this field, so the server holds the rows to its `filterOptions`. */
288
+ filterOptions?: true;
277
289
  };
278
290
  /** One field as a screen draws it: the data a browser needs, and this screen's drawing facts decided. */
279
291
  export type ClientField = FieldData & {
@@ -337,6 +349,11 @@ export type ListClient = {
337
349
  row?: ButtonClient[];
338
350
  sort?: string;
339
351
  limit?: number;
352
+ tabs?: {
353
+ tab: string;
354
+ label: Localized;
355
+ where?: Where;
356
+ }[];
340
357
  /** A list of another kayan: its name, and its relationship holding this page's row. */
341
358
  of?: string;
342
359
  where?: string;
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { builtInScreenKinds, choiceControls, packageName, registryCells, registryControls } from './client.js';
3
3
  import { unsortable } from '../mithaq/types.js';
4
4
  import { defaultKinds } from '../base/kinds.js';
5
- import { assertFieldWhere } from '../asas/runner/write.js';
5
+ import { assertFieldWhere, assertWhere } from '../asas/runner/write.js';
6
6
  import { bulkSeals, readSeals, sealOf, storeSeals } from '../asas/runner/steps.js';
7
7
  import { kindOf } from './studio.js';
8
8
  import { implementationOf } from '../mithaq/rukn.js';
@@ -40,13 +40,14 @@ export const clientFieldOf = (name, field) => ({
40
40
  ? { relationTo: field.relationTo().name }
41
41
  : {}),
42
42
  ...(field.hasMany === true && { hasMany: true }),
43
+ ...(typeof field.filterOptions === 'function' && { filterOptions: true }),
43
44
  ...(typeof field.on === 'string' && { on: field.on }),
44
45
  });
45
46
  const word = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u;
46
47
  const targetKeys = ['capability', 'type', 'path', 'kayan', 'controls', 'providers', 'pages', 'layouts', 'components'];
47
48
  const leafKeys = ['field', 'component', 'cell', 'placeholder', 'description', 'readOnly', 'width', 'when'];
48
49
  const buttonKeys = ['button', 'label', 'fields', 'reload', 'component', 'confirm', 'when'];
49
- const listKeys = ['columns', 'filters', 'search', 'empty', 'menu', 'toolbar', 'bulk', 'row', 'sort', 'limit', 'of', 'where'];
50
+ const listKeys = ['columns', 'filters', 'search', 'empty', 'menu', 'toolbar', 'bulk', 'row', 'sort', 'limit', 'tabs', 'of', 'where'];
50
51
  const modes = ['dialog', 'sheet', 'drawer'];
51
52
  /** Every key of `value` is one `known` names, so nothing reaches `screen.json` unnamed. */
52
53
  const named = (at, value, known, what) => {
@@ -177,6 +178,11 @@ export const screensOf = (language, resolved) => {
177
178
  const map = place === 'layout' ? 'layouts' : 'components';
178
179
  if (Object.hasOwn(maps[map], value))
179
180
  return maps[map][value];
181
+ // A kind no registry control or cell draws names itself, and the project's components map draws it under that name.
182
+ if (place === 'control' && value === kind && !Object.hasOwn(shippedControls, value) && !registryControls.includes(value))
183
+ return value;
184
+ if (place === 'cell' && value === kind && !shippedCells.includes(value) && !registryCells.includes(value))
185
+ return value;
180
186
  const shipped = place === 'control' ? Object.keys(shippedControls) : place === 'cell' ? shippedCells : [];
181
187
  if (!shipped.includes(value)) {
182
188
  throw new Error(`${at} names "${value}", which is no name in this screen's ${map} and no ${place} the registry ships.`);
@@ -199,7 +205,7 @@ export const screensOf = (language, resolved) => {
199
205
  throw new Error(`${screen} controls has no row for kind "${kind}", and every kind of the language needs one.`);
200
206
  controls[kind] = {
201
207
  control: ref(`${screen} controls "${kind}" control`, 'control', row.control, kind),
202
- cell: ref(`${screen} controls "${kind}" cell`, 'cell', row.cell),
208
+ cell: ref(`${screen} controls "${kind}" cell`, 'cell', row.cell, kind),
203
209
  };
204
210
  }
205
211
  for (const name of target.kayan) {
@@ -224,7 +230,7 @@ export const screensOf = (language, resolved) => {
224
230
  ...(typeof field.suffix === 'object' && { suffix: field.suffix }),
225
231
  ...(typeof field.description === 'object' && { description: field.description }),
226
232
  component,
227
- cell: own.cell ? ref(`${where} targets.${key}.cell`, 'cell', own.cell) : controls[field.kind].cell,
233
+ cell: own.cell ? ref(`${where} targets.${key}.cell`, 'cell', own.cell, field.kind) : controls[field.kind].cell,
228
234
  ...(typeof own.width === 'string' && { width: own.width }),
229
235
  ...(own.filter === true && { filter: true }),
230
236
  ...(own.searchable === true && { searchable: true }),
@@ -370,7 +376,7 @@ export const screensOf = (language, resolved) => {
370
376
  ...(typeof readOnly === 'boolean' && { readOnly }),
371
377
  ...(typeof width === 'string' && { width }),
372
378
  ...(place === 'control' && { component: control }),
373
- ...((place === 'cell' || cell) && { cell: cell ? ref(`${node} "${name}" cell`, 'cell', cell) : field.cell }),
379
+ ...((place === 'cell' || cell) && { cell: cell ? ref(`${node} "${name}" cell`, 'cell', cell, field.kind) : field.cell }),
374
380
  ...(placeholder !== undefined && { placeholder: copy(`${node} "${name}" placeholder`, placeholder) }),
375
381
  ...(description !== undefined && { description: copy(`${node} "${name}" description`, description) }),
376
382
  decided,
@@ -443,7 +449,7 @@ export const screensOf = (language, resolved) => {
443
449
  return { ...drawn, list: { of, where: where, ...drawn.list } };
444
450
  }
445
451
  held.list++;
446
- const { columns, filters, search, empty, menu, sort, limit } = one.list;
452
+ const { columns, filters, search, empty, menu, sort, limit, tabs } = one.list;
447
453
  // Where a button sits decides which operations it may run: bulk over ticked rows,
448
454
  // row over its row, toolbar over none.
449
455
  const buttons = (region) => {
@@ -480,6 +486,17 @@ export const screensOf = (language, resolved) => {
480
486
  if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1 && limit <= maxLimit)) {
481
487
  throw new Error(`${node} list limit is ${JSON.stringify(limit)}, and a limit is a whole number from 1 to ${maxLimit}.`);
482
488
  }
489
+ if (tabs !== undefined && (!Array.isArray(tabs) || !tabs.length))
490
+ throw new Error(`${node} list tabs is no list of tabs.`);
491
+ const tabbed = tabs?.map((tab, at) => {
492
+ const said = `${node} list tab ${at}`;
493
+ named(said, tab, ['tab', 'label', 'where'], 'tab');
494
+ // Written into the URL as itself, so a word, once per list.
495
+ if (typeof tab.tab !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(tab.tab) || tabs.findIndex((other) => other.tab === tab.tab) !== at) {
496
+ throw new Error(`${said} is named ${JSON.stringify(tab.tab)}, and a tab is named once in a list, in letters and digits.`);
497
+ }
498
+ return { tab: tab.tab, label: copy(`${said} label`, tab.label), ...(tab.where !== undefined && { where: assertWhere(kayan, tab.where) }) };
499
+ });
483
500
  return {
484
501
  list: {
485
502
  columns: columns.map((column) => leafOf(`${node} list column`, column, 'cell')),
@@ -492,6 +509,7 @@ export const screensOf = (language, resolved) => {
492
509
  ...(row && { row }),
493
510
  ...(sort !== undefined && { sort }),
494
511
  ...(limit !== undefined && { limit }),
512
+ ...(tabbed && { tabs: tabbed }),
495
513
  },
496
514
  ...drawnBy(node, one),
497
515
  };
@@ -18,10 +18,12 @@ export type ScreenState = LocaleState & {
18
18
  name: string;
19
19
  path: string;
20
20
  }[];
21
+ /** `names` is each locale's name, the JSON the project's auth setup writes; `name` is the default locale's. */
21
22
  organizations?: readonly {
22
23
  id: string;
23
24
  name: string;
24
25
  slug?: string;
26
+ names?: string;
25
27
  }[];
26
28
  activeOrganizationId?: string | null;
27
29
  };
@@ -1,11 +1,10 @@
1
1
  import type { StandardSchemaV1 } from '../mithaq/standard-schema.ts';
2
2
  import type { ResolvedField } from '../mithaq/types.ts';
3
- import type { Tables } from '../drizzle/index.ts';
4
3
  import { type StorageConfig } from './storage.ts';
5
4
  /** A relationship to a kayan that holds files, drawn as a file picker. */
6
5
  export declare const upload: import("../base/kinds.ts").Kind<string | number, "defaultValue" | "index" | "onDelete" | "providedBy" | "required" | "unique"> & {
7
6
  targets: {
8
- readonly drizzle: (field: ResolvedField, tables: Tables) => import("drizzle-orm/pg-core").AnyPgColumnBuilder;
7
+ readonly drizzle: import("../targets.ts").ColumnBuilder;
9
8
  readonly zod: (field: ResolvedField) => import("zod").ZodArray<import("zod").ZodGUID | import("zod").ZodInt32 | import("zod").ZodString> | import("zod").ZodGUID | import("zod").ZodInt32 | import("zod").ZodString;
10
9
  readonly standardSchema: (field: ResolvedField) => StandardSchemaV1<unknown, number | string>;
11
10
  readonly screen: {
@@ -25,7 +24,7 @@ export declare const storage: (config: StorageConfig) => {
25
24
  readonly kinds: {
26
25
  readonly upload: import("../base/kinds.ts").Kind<string | number, "defaultValue" | "index" | "onDelete" | "providedBy" | "required" | "unique"> & {
27
26
  targets: {
28
- readonly drizzle: (field: ResolvedField, tables: Tables) => import("drizzle-orm/pg-core").AnyPgColumnBuilder;
27
+ readonly drizzle: import("../targets.ts").ColumnBuilder;
29
28
  readonly zod: (field: ResolvedField) => import("zod").ZodArray<import("zod").ZodGUID | import("zod").ZodInt32 | import("zod").ZodString> | import("zod").ZodGUID | import("zod").ZodInt32 | import("zod").ZodString;
30
29
  readonly standardSchema: (field: ResolvedField) => StandardSchemaV1<unknown, number | string>;
31
30
  readonly screen: {
@@ -21,8 +21,7 @@ export const upload = kind((value) => typeof value === 'number' || typeof value
21
21
  // A file a row still names is never deleted from under it, whether or not the field is required.
22
22
  onDelete: 'restrict',
23
23
  targets: {
24
- // A set's join table restricts too, where a relationship's cascades.
25
- drizzle: (field, tables) => drizzleKinds.relationship(field.hasMany ? { ...field, rules: { ...field.rules, onDelete: 'restrict' } } : field, tables),
24
+ drizzle: drizzleKinds.relationship,
26
25
  zod: zodKinds.relationship,
27
26
  standardSchema: (field) => ({
28
27
  '~standard': {
@@ -226,6 +226,8 @@ export type StorageConfig = {
226
226
  adapter: string;
227
227
  /** The largest file in bytes a kayan holds when its `targets.storage` names no `fileSize`. */
228
228
  fileSize: number;
229
+ /** The largest request body `uploadInstructions` reads, answered 413 past it: `defaultMaxBodyBytes` or the project's own. */
230
+ maxBodyBytes: number;
229
231
  /** The object key, decided before the upload. */
230
232
  key: (args: {
231
233
  prefix: string;
@@ -4,8 +4,9 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
4
4
  import { drizzle } from 'drizzle-orm/node-postgres';
5
5
  import { isStorage } from '../mithaq/language.js';
6
6
  import { providerOf } from '../base/rules.js';
7
- import { AsasError, codes, denied, invalid, missing } from '../asas/runner/errors.js';
7
+ import { AsasError, retryConflict, codes, denied, invalid, missing } from '../asas/runner/errors.js';
8
8
  import { sealOf } from '../asas/runner/steps.js';
9
+ import { assertMaxBodyBytes, boundedBody } from '../asas/http/fetch.js';
9
10
  import { allowedType } from './allowed-type.js';
10
11
  /** Written by the runner when the row is made, from the signed upload, and by nobody after. */
11
12
  const fileColumn = { create: 'application', update: 'never' };
@@ -181,6 +182,7 @@ export const resolveStorage = ({ language, resolved, storeOf, active }) => {
181
182
  if (!Number.isSafeInteger(given.fileSize) || given.fileSize < 1) {
182
183
  throw new Error(`storage.fileSize is ${JSON.stringify(given.fileSize)}, and it is a whole number of bytes above 0.`);
183
184
  }
185
+ assertMaxBodyBytes('storage', given.maxBodyBytes);
184
186
  if (typeof given.onError !== 'function')
185
187
  throw new Error('storage names no "onError" function, which a file route hands what it threw before it answers 500. Name the project\'s own, such as console.error.');
186
188
  const adapters = Object.entries(given.adapters ?? {});
@@ -323,9 +325,13 @@ export const storageRoutes = ({ kayan, config, read, authorize, admin, }) => {
323
325
  return null;
324
326
  try {
325
327
  if (verb === 'uploadInstructions' && id === undefined && request.method === 'POST') {
328
+ // Bounded before the actor is asked, so no caller holds the server to a body of any size.
329
+ const read = await boundedBody(request, config.maxBodyBytes);
330
+ if (read instanceof Response)
331
+ return read;
326
332
  let input;
327
333
  try {
328
- input = await request.json();
334
+ input = JSON.parse(await read.text());
329
335
  }
330
336
  catch {
331
337
  input = undefined;
@@ -347,6 +353,7 @@ export const storageRoutes = ({ kayan, config, read, authorize, admin, }) => {
347
353
  return new Response(served.body, { status: served.status, statusText: served.statusText, headers });
348
354
  }
349
355
  catch (thrown) {
356
+ thrown = retryConflict(thrown);
350
357
  if (thrown instanceof AsasError)
351
358
  return refusal(thrown);
352
359
  config.onError(thrown);
@@ -0,0 +1,14 @@
1
+ export { drizzleAdapter, type Adapter, type ColumnBuilder, type ColumnRule } from './drizzle/index.ts';
2
+ export { kinds as drizzleKinds, idKinds as drizzleIdKinds } from './drizzle/kinds.ts';
3
+ export { rules as drizzleRules } from './drizzle/rules.ts';
4
+ export { zodTarget, type ZodRule, type ZodShape } from './zod/index.ts';
5
+ export { kinds as zodKinds, idKinds as zodIdKinds } from './zod/kinds.ts';
6
+ export { rules as zodRules } from './zod/rules.ts';
7
+ export { defaultBulkLimit, defaultDepth, defaultJoinLimit, defaultLimit, defaultMaxLimit, serviceTarget, type ServiceTarget } from './service/index.ts';
8
+ export { defaultAuthorize } from './asas/runner/write.ts';
9
+ export { rbacTarget, type AccessOptions, type AccessTarget, type SystemRole, type SystemRoles } from './rbac/index.ts';
10
+ export { betterAuthTarget, memberRoleTarget, type BetterAuthAccessOptions } from './auth/better-auth/access.ts';
11
+ export { betterAuthTenant } from './auth/better-auth/tenant.ts';
12
+ export { screenKinds, screenTarget, type ScreenTarget } from './screen/index.ts';
13
+ export { defaultFileSize, storageTarget, type StorageConfig, type StorageTarget } from './storage/storage.ts';
14
+ export { versionsTarget, type KayanVersions, type VersionsTarget } from './versions/index.ts';
@@ -0,0 +1,16 @@
1
+ // Every target factory mithaq ships and the named defaults its options take, so a project's
2
+ // `targets` block reads from one place: `import { drizzleAdapter, serviceTarget } from 'mithaq/targets'`.
3
+ export { drizzleAdapter } from './drizzle/index.js';
4
+ export { kinds as drizzleKinds, idKinds as drizzleIdKinds } from './drizzle/kinds.js';
5
+ export { rules as drizzleRules } from './drizzle/rules.js';
6
+ export { zodTarget } from './zod/index.js';
7
+ export { kinds as zodKinds, idKinds as zodIdKinds } from './zod/kinds.js';
8
+ export { rules as zodRules } from './zod/rules.js';
9
+ export { defaultBulkLimit, defaultDepth, defaultJoinLimit, defaultLimit, defaultMaxLimit, serviceTarget } from './service/index.js';
10
+ export { defaultAuthorize } from './asas/runner/write.js';
11
+ export { rbacTarget } from './rbac/index.js';
12
+ export { betterAuthTarget, memberRoleTarget } from './auth/better-auth/access.js';
13
+ export { betterAuthTenant } from './auth/better-auth/tenant.js';
14
+ export { screenKinds, screenTarget } from './screen/index.js';
15
+ export { defaultFileSize, storageTarget } from './storage/storage.js';
16
+ export { versionsTarget } from './versions/index.js';
package/dist/vite.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { type Plugin } from 'vite';
2
+ /**
3
+ * Serves the admin's pieces from the installed `mithaq-admin`, and a file the project ejected into `ejected`
4
+ * in place of the package's file of the same name, for every importer: the app, the package's own pieces and
5
+ * other ejected files. Deleting the ejected file returns the project to the package's.
6
+ */
7
+ export declare const mithaqAdmin: (ejected: string) => Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,81 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { normalizePath } from 'vite';
5
+ /**
6
+ * Serves the admin's pieces from the installed `mithaq-admin`, and a file the project ejected into `ejected`
7
+ * in place of the package's file of the same name, for every importer: the app, the package's own pieces and
8
+ * other ejected files. Deleting the ejected file returns the project to the package's.
9
+ */
10
+ export const mithaqAdmin = (ejected) => {
11
+ // Resolved from the project, so a store layout finds the copy the project installed.
12
+ const registry = join(dirname(createRequire(join(ejected, 'x')).resolve('mithaq-admin/package.json')), 'registry/');
13
+ return {
14
+ name: 'mithaq-admin',
15
+ enforce: 'pre',
16
+ // Pre-bundled, a piece the app imports and the same piece a sibling imports are two modules in dev.
17
+ // No `server.warmup` of the package: measured cold, it served the first page 0.7 s later and loaded the
18
+ // client 1 s sooner, and it set the optimizer discovering dependencies no page asked for yet.
19
+ config: () => ({ optimizeDeps: { exclude: ['mithaq-admin'] } }),
20
+ // The dependency scan stops at the excluded package, so what only the pieces import (Lexical, TanStack Form, and
21
+ // through the app's own primitives, Base UI) would be found by the first page that needs it, re-optimized mid-page
22
+ // into a second React, and the page reloaded. Named up front instead, but only what the project installed from a
23
+ // registry: an optional peer it left out (Lexical without rich text) fails the whole optimization, and a linked
24
+ // workspace package is served as source, where an include would stop the scan finding what it imports. And only
25
+ // what nothing excludes, since an include wins over an exclude: last, after every plugin has named its own.
26
+ configEnvironment: { order: 'post', handler(name, { optimizeDeps, resolve }) {
27
+ if (name !== 'client')
28
+ return;
29
+ // ponytail: an exclude a plugin adds later than configEnvironment (in configResolved) is not seen here and loses
30
+ // to the include; read the resolved config there if a plugin ever does.
31
+ const excluded = optimizeDeps?.exclude ?? [];
32
+ const folders = createRequire(join(ejected, 'x')).resolve.paths('x') ?? [];
33
+ const alias = resolve?.alias ?? [];
34
+ const aliases = Array.isArray(alias) ? alias : Object.entries(alias).map(([find, replacement]) => ({ find, replacement }));
35
+ /** The file of the app's a relative or aliased specifier names, '' where none is, or nothing for a package. */
36
+ const local = (specifier, from) => {
37
+ const entry = aliases.find(({ find }) => (typeof find === 'string' ? specifier === find || specifier.startsWith(`${find}/`) : find.test(specifier)));
38
+ const path = specifier.startsWith('.') ? join(dirname(from), specifier) : entry && specifier.replace(entry.find, entry.replacement);
39
+ return path === undefined ? undefined : (['', '.tsx', '.ts'].map((extension) => path + extension).find((file) => statSync(file, { throwIfNoEntry: false })?.isFile()) ?? '');
40
+ };
41
+ // The pieces, and the app's own files they reach, such as the primitives the published package leaves out.
42
+ // What a project ejects into its language package (mail, steps, storage) runs on the server alone.
43
+ const files = new Set(readdirSync(registry, { recursive: true, encoding: 'utf8' }).filter((file) => /\.tsx?$/.test(file) && !/^(mail|steps|storage)\//.test(normalizePath(file))).map((file) => join(registry, file)));
44
+ const imports = new Set();
45
+ for (const file of files) {
46
+ // ponytail: a regex over the source, which a ` from '...'` inside a string or a comment fools into one more
47
+ // include; parse the files if a piece ever reads that way.
48
+ for (const [, type, specifier] of readFileSync(file, 'utf8').matchAll(/^(?:import|export)\s+(type\s)?(?:[^'";]*?\sfrom\s*)?['"]([^'"]+)['"]/gm)) {
49
+ if (type)
50
+ continue;
51
+ const found = local(specifier, file);
52
+ if (found)
53
+ files.add(found);
54
+ else if (found === undefined)
55
+ imports.add(specifier);
56
+ }
57
+ }
58
+ const include = [...imports].filter((specifier) => {
59
+ // `mithaq` too, which the app's own code imports, so the scan finds it: named here, its rich text would be
60
+ // bundled where no Lexical is installed.
61
+ if (/^(\/|node:|mithaq(\/|$))/.test(specifier))
62
+ return false;
63
+ const pkg = specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/');
64
+ const installed = folders.map((folder) => join(folder, pkg)).find((folder) => existsSync(join(folder, 'package.json')));
65
+ return !excluded.includes(pkg) && !excluded.includes(specifier) && !!installed && normalizePath(realpathSync(installed)).includes('/node_modules/');
66
+ });
67
+ return { optimizeDeps: { include: include.sort() } };
68
+ } },
69
+ // By the specifier, not by resolving it: published as `@drhomet/mithaq-admin`, a piece in a store's folder cannot
70
+ // reach `mithaq-admin/<file>` by walking up, so the plugin answers for every importer.
71
+ resolveId(source) {
72
+ const [, file] = /^mithaq-admin\/(.+\.tsx?)$/.exec(source) ?? [];
73
+ // A piece names a sibling by its file alone, so a climb out of the package is no import of ours.
74
+ if (!file || file.split('/').includes('..'))
75
+ return;
76
+ const own = join(ejected, file);
77
+ // The real path, as Vite names every other module, so a symlinked folder is never a second module.
78
+ return normalizePath(existsSync(own) ? realpathSync(own) : join(registry, file));
79
+ },
80
+ };
81
+ };
@@ -32,6 +32,7 @@ type ZodShapes<F extends Record<string, RuntimeField>> = {
32
32
  declare const listShape: z.ZodObject<{
33
33
  data: z.ZodOptional<z.ZodUnknown>;
34
34
  depth: z.ZodOptional<z.ZodUnknown>;
35
+ for: z.ZodOptional<z.ZodUnknown>;
35
36
  limit: z.ZodOptional<z.ZodUnknown>;
36
37
  page: z.ZodOptional<z.ZodUnknown>;
37
38
  sort: z.ZodOptional<z.ZodUnknown>;
@@ -58,8 +59,9 @@ declare const target: {
58
59
  textArray: (field?: ResolvedField) => z.ZodArray<z.ZodEnum<{
59
60
  [x: string]: string;
60
61
  }> | z.ZodString>;
61
- uuid: (field?: ResolvedField) => z.ZodGUID;
62
+ uuid: (field?: ResolvedField) => z.ZodGUID | z.ZodInt32 | z.ZodString;
62
63
  relationship: (field: ResolvedField) => z.ZodArray<z.ZodGUID | z.ZodInt32 | z.ZodString> | z.ZodGUID | z.ZodInt32 | z.ZodString;
64
+ json: (field?: ResolvedField) => z.ZodCustom<import("../base/kinds.ts").Json, import("../base/kinds.ts").Json>;
63
65
  join: (field?: ResolvedField) => z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
64
66
  };
65
67
  readonly rules: {
@@ -158,7 +160,7 @@ declare const target: {
158
160
  }) => z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
159
161
  defaultValue: ({ shape }: {
160
162
  shape: z.ZodType;
161
- value: string | number | boolean | Date | readonly unknown[] | import("../index.ts").Ref;
163
+ value: Date | readonly unknown[] | import("../index.ts").Ref | import("../base/kinds.ts").Json;
162
164
  field: ResolvedField;
163
165
  }) => z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
164
166
  defaultNow: ({ shape, value: on }: {
@@ -178,7 +180,14 @@ declare const target: {
178
180
  }, fields: F) => ZodShapes<F>;
179
181
  };
180
182
  /** The zod schema target. */
181
- export declare const zodTarget: typeof target & {
183
+ export declare const zodTarget: <const Kinds extends Record<string, ZodShape>, const Rules extends Record<string, ZodRule>, const IdKinds extends readonly string[]>({ kinds, rules, idKinds }: {
184
+ kinds: Kinds;
185
+ rules: Rules;
186
+ idKinds: IdKinds;
187
+ }) => Omit<typeof target, 'kinds' | 'rules' | 'idKinds'> & {
188
+ kinds: Kinds;
189
+ rules: Rules;
190
+ idKinds: IdKinds;
182
191
  readonly derives?: ZodDerives;
183
192
  };
184
193
  export {};
package/dist/zod/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { storedRichText } from '../rich-text/rich-text.js';
2
+ import { storedRichText } from '../rich-text/value.js';
3
3
  import { providerOf } from '../base/rules.js';
4
4
  import { idKinds as zodIdKinds, kinds as zodKinds } from './kinds.js';
5
5
  import { implementationOf } from '../mithaq/rukn.js';
@@ -74,6 +74,7 @@ const rowSchema = (schema, fields) => z.object(Object.fromEntries(Object.entries
74
74
  const listShape = z.strictObject({
75
75
  data: z.unknown().optional(),
76
76
  depth: z.unknown().optional(),
77
+ for: z.unknown().optional(),
77
78
  limit: z.unknown().optional(),
78
79
  page: z.unknown().optional(),
79
80
  sort: z.unknown().optional(),
@@ -88,11 +89,11 @@ const target = {
88
89
  rules: zodRules,
89
90
  derive(fields) {
90
91
  const built = fieldShapes(fields, this.kinds, this.rules);
91
- // A stored row is read against its kinds alone, with no rule and no `options`,
92
- // so a rule or option added after rows were stored refuses their next save, never their read.
92
+ // A stored row is read against its kinds alone, with no rule, `options`, `jsonSchema` or `maxBytes`,
93
+ // so one added after rows were stored refuses their next save, never their read.
93
94
  const kinds = Object.fromEntries(Object.entries(fields).map(([name, field]) => [
94
95
  name,
95
- { ...field, options: undefined, ...(field.kind === 'richText' && { richText: storedRichText }) },
96
+ { ...field, options: undefined, jsonSchema: undefined, maxBytes: undefined, ...(field.kind === 'richText' && { richText: storedRichText }) },
96
97
  ]));
97
98
  const stored = fieldShapes(kinds, this.kinds, { localized: this.rules.localized });
98
99
  const drafted = fieldShapes(Object.fromEntries(Object.entries(kinds).map(([name, field]) => [name, field.kind === 'richText' ? { ...field, richText: fields[name].richText, rules: { ...field.rules, required: false } } : field])), this.kinds, { localized: this.rules.localized });
@@ -118,4 +119,4 @@ const target = {
118
119
  },
119
120
  };
120
121
  /** The zod schema target. */
121
- export const zodTarget = target;
122
+ export const zodTarget = ({ kinds, rules, idKinds }) => ({ ...target, kinds, rules, idKinds });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ResolvedField } from '../mithaq/types.ts';
3
+ import { type Json } from '../base/kinds.ts';
3
4
  export declare const errorOf: (field?: ResolvedField, element?: boolean) => ((issue: {
4
5
  input: unknown;
5
6
  }) => string) | undefined;
@@ -27,8 +28,9 @@ export declare const kinds: {
27
28
  textArray: (field?: ResolvedField) => z.ZodArray<z.ZodEnum<{
28
29
  [x: string]: string;
29
30
  }> | z.ZodString>;
30
- uuid: (field?: ResolvedField) => z.ZodGUID;
31
+ uuid: (field?: ResolvedField) => z.ZodGUID | z.ZodInt32 | z.ZodString;
31
32
  relationship: (field: ResolvedField) => z.ZodArray<z.ZodGUID | z.ZodInt32 | z.ZodString> | z.ZodGUID | z.ZodInt32 | z.ZodString;
33
+ json: (field?: ResolvedField) => z.ZodCustom<Json, Json>;
32
34
  join: (field?: ResolvedField) => z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
33
35
  };
34
36
  export {};
package/dist/zod/kinds.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
+ import { isJson } from '../base/kinds.js';
2
3
  import { isDecimal } from '../base/decimal.js';
4
+ import { jsonSchemaCheck } from '../base/json-schema.js';
3
5
  // A missing key, or a `null` on a required field, is the `required` rule refusing;
4
6
  // anything else is the kind refusing the value. A field with no kayan keeps zod's text.
5
7
  export const errorOf = (field, element = false) => field?.kayan
@@ -12,7 +14,8 @@ const idShapes = {
12
14
  number: (error) => z.int32({ error }),
13
15
  text: (error) => z.string({ error }),
14
16
  // `guid`, not `uuid`: `z.uuid()` also demands an RFC version and variant, which Postgres does not.
15
- uuid: (error) => z.guid({ error }),
17
+ // Lower-cased, the spelling Postgres answers, so a step comparing ids as text sees one id.
18
+ uuid: (error) => z.guid({ error }).toLowerCase(),
16
19
  };
17
20
  /** The kinds this target shapes an id of, the same three the store's columns are. */
18
21
  export const idKinds = Object.keys(idShapes);
@@ -40,10 +43,16 @@ export const kinds = {
40
43
  date: (field) => z.iso.date({ error: errorOf(field) }),
41
44
  // The same error on each element, so a bad element reads as the field's own sentence.
42
45
  textArray: (field) => z.array(textShape(field, true), { error: errorOf(field) }),
43
- uuid: (field) => z.guid({ error: errorOf(field) }),
46
+ uuid: (field) => idShape('uuid', errorOf(field)),
44
47
  relationship: (field) => field.hasMany
45
48
  ? z.array(idShape(field.target.fields.id.kind, errorOf(field, true)), { error: errorOf(field) })
46
49
  : idShape(field.target.fields.id.kind, errorOf(field)),
50
+ // Only what JSON can say, so a value reads back as it was written; `z.json()` lets a cycle through.
51
+ // A `jsonSchema` only judges: what is stored is the value as sent.
52
+ json: (field) => {
53
+ const shape = z.custom((value) => isJson(value) && !(field?.maxBytes && new TextEncoder().encode(JSON.stringify(value)).length > field.maxBytes), { error: errorOf(field) });
54
+ return field?.jsonSchema ? shape.refine(jsonSchemaCheck(field), { error: errorOf(field) }) : shape;
55
+ },
47
56
  // Left loose: the child's row shape may not be derived yet, and the child's own read already parsed the rows.
48
57
  join: (field) => z.array(z.record(z.string(), z.unknown()), { error: errorOf(field) }),
49
58
  };
@@ -99,7 +99,7 @@ export declare const rules: {
99
99
  }) => z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
100
100
  defaultValue: ({ shape }: {
101
101
  shape: z.ZodType;
102
- value: string | number | boolean | Date | readonly unknown[] | import("../index.ts").Ref;
102
+ value: Date | readonly unknown[] | import("../index.ts").Ref | import("../index.ts").Json;
103
103
  field: ResolvedField;
104
104
  }) => z.ZodOptional<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
105
105
  /** The moment the defaults step writes, which leaves the key optional as a literal does. */