@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,59 +1,56 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { createHash } from 'node:crypto';
3
2
  import { existsSync } from 'node:fs';
4
- import { readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
4
  import { createRequire } from 'node:module';
6
- import { fileURLToPath, pathToFileURL } from 'node:url';
7
- // The last line of every registry file: the commit it was built at and the hash of the
8
- // file above. Trailing, because `shadcn add` drops a file's leading comments only.
5
+ import { pathToFileURL } from 'node:url';
6
+ // The last line of every ejected file: the version (or, for a copy `shadcn add` made, the commit) it came from and the
7
+ // hash of the file above. Trailing, because `shadcn add` dropped a file's leading comments only.
9
8
  const marker = /\n\/\/ mithaq-admin (\S+) ([0-9a-f]{64})\n?$/;
10
- // What `shadcn add` drops, so a hash is the same over the registry's text and the copy's.
9
+ // What `shadcn add` dropped, so a hash is the same over the package's text and a copy it made.
11
10
  const leading = /^(?:\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/;
12
- /** The hash of a file as `shadcn add` writes it, without its marker. */
13
- export const hashOf = (text) => createHash('sha256').update(text.replace(marker, '\n').replace(leading, '')).digest('hex');
14
- /** Appends the marker to every file of every item `shadcn build` wrote into `built`, the registry's `r/`. */
15
- export const mark = async (built) => {
16
- let commit = 'unknown';
17
- try {
18
- commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: fileURLToPath(built), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
19
- }
20
- catch { }
21
- for (const name of await readdir(built)) {
22
- if (!name.endsWith('.json') || name === 'registry.json')
23
- continue;
24
- const item = JSON.parse(await readFile(new URL(name, built), 'utf8'));
25
- for (const file of item.files ?? []) {
26
- const body = file.content.replace(marker, '\n');
27
- const text = body.endsWith('\n') ? body : `${body}\n`;
28
- file.content = `${text}// mithaq-admin ${commit} ${hashOf(text)}\n`;
29
- }
30
- await writeFile(new URL(name, built), `${JSON.stringify(item, null, 2)}\n`);
31
- }
11
+ /** What an eject writes above the marker: the file's text, ending in a newline. */
12
+ const bodyOf = (text) => {
13
+ const body = text.replace(marker, '\n');
14
+ return body.endsWith('\n') ? body : `${body}\n`;
15
+ };
16
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
17
+ /** The hash of a copy `shadcn add` made, without its marker and the leading comments it dropped. */
18
+ export const hashOf = (text) => sha(bodyOf(text).replace(leading, ''));
19
+ /**
20
+ * The hash a marker from `from` holds: of the whole text above it for an eject, which writes byte for byte, so an edit
21
+ * to a leading pragma is an edit; without leading comments only for a copy made at a commit, as `shadcn add` wrote it.
22
+ */
23
+ const madeByShadcn = (from) => /^(?:[0-9a-f]{40}|unknown)$/.test(from);
24
+ const hashFrom = (from) => (text) => (madeByShadcn(from) ? hashOf(text) : sha(bodyOf(text)));
25
+ /** A held file's state against the package's text of it, read as `upgrade --check` reads it. */
26
+ export const stateOf = (text, source) => {
27
+ const [, from, hash] = marker.exec(text) ?? [];
28
+ if (!hash)
29
+ return 'unknown';
30
+ const hashed = hashFrom(from);
31
+ return `${hash !== hashed(source) ? 'differs' : 'up to date'}${hash !== hashed(text) ? ', edited' : ''}`;
32
32
  };
33
- // Beside `dist/` when published, at the same depth from this file.
34
- // Beside `dist/` when published; in this repository the apps read the workspace's `mithaq-admin`.
35
- const registry = new URL('../../../r/', import.meta.url);
36
- // The installed `mithaq-admin`'s `r/`, else the one this mithaq ships, else none.
37
- const registryOf = (project) => {
33
+ /** The `mithaq-admin` the project installed: its folder, its version and its items, or none. */
34
+ const installedOf = async (project) => {
35
+ let manifest;
38
36
  try {
39
- return new URL('r/', pathToFileURL(createRequire(new URL('package.json', project)).resolve('mithaq-admin/package.json')));
37
+ manifest = pathToFileURL(createRequire(new URL('package.json', project)).resolve('mithaq-admin/package.json'));
40
38
  }
41
39
  catch {
42
- return existsSync(new URL('registry.json', registry)) ? registry : undefined;
40
+ return undefined;
43
41
  }
42
+ const root = new URL('./', manifest);
43
+ const { version } = JSON.parse(await readFile(manifest, 'utf8'));
44
+ const { items } = JSON.parse(await readFile(new URL('registry.json', root), 'utf8'));
45
+ return { root, version, items };
44
46
  };
45
- /**
46
- * Every registry file the project holds where `shadcn add` writes it, with its state. Reads only;
47
- * a path that climbs out of the project or the registry is listed as outside, never followed.
48
- */
49
- export const upgradeCheck = async (project) => {
47
+ // `new URL` resolves every `..`, encoded or not, so a prefix is the whole test.
48
+ const within = (url, folder) => url.href.startsWith(folder.href);
49
+ /** Where the project keeps each target: `@<alias>/…` through `components.json`, `~/…` from the project's root. */
50
+ const targetsOf = async (project) => {
50
51
  const components = JSON.parse(await readFile(new URL('components.json', project), 'utf8'));
51
- const built = registryOf(project);
52
- if (!built)
53
- return undefined;
54
- const { items } = JSON.parse(await readFile(new URL('registry.json', built), 'utf8'));
55
52
  const src = existsSync(new URL('src/', project)) ? 'src/' : '';
56
- const pathOf = (target) => {
53
+ return (target) => {
57
54
  if (target.startsWith('~/'))
58
55
  return target.slice(2);
59
56
  const [, alias = '', rest] = /^@([^/]+)\/(.+)$/.exec(target) ?? [];
@@ -62,44 +59,49 @@ export const upgradeCheck = async (project) => {
62
59
  const at = components.aliases?.[alias];
63
60
  return at?.startsWith('@/') ? `${src}${at.slice(2)}/${rest}` : undefined;
64
61
  };
62
+ };
63
+ const folderOf = (path) => path.slice(0, path.lastIndexOf('/') + 1);
64
+ /** The folders a piece the package serves (`registry/<file>`) is ejected into: what `eject --prune` sweeps. */
65
+ const ejectedFolders = (items, pathOf) => new Set(items.flatMap((item) => item.files.filter((file) => !file.path.slice('registry/'.length).includes('/')).flatMap((file) => { const path = pathOf(file.target); return path ? folderOf(path) : []; })));
66
+ /**
67
+ * Every package file the project holds at its target, ejected or always the project's, with its state. Reads only;
68
+ * a path that climbs out of the project or the package is listed as outside, never followed.
69
+ */
70
+ export const upgradeCheck = async (project) => {
71
+ const pathOf = await targetsOf(project);
72
+ const installed = await installedOf(project);
73
+ if (!installed)
74
+ return undefined;
65
75
  const copied = [];
66
- /** Every path an item names, copied or not, and the folders they sit in, which a file no item names any more is looked for in. */
76
+ /** Every path an item names, held or not, and the folders they sit in, which a file no item names any more is looked for in. */
67
77
  const named = new Set();
68
78
  const folders = new Set();
69
- // `new URL` resolves every `..`, encoded or not, so a prefix is the whole test.
70
- const within = (url, folder) => url.href.startsWith(folder.href);
71
- for (const { name } of items) {
72
- const source = new URL(`${name}.json`, built);
73
- if (!within(source, built)) {
74
- copied.push({ path: name, state: 'outside' });
75
- continue;
76
- }
77
- const item = JSON.parse(await readFile(source, 'utf8'));
78
- for (const file of item.files ?? []) {
79
+ const ejected = ejectedFolders(installed.items, pathOf);
80
+ for (const { name, files } of installed.items) {
81
+ for (const file of files) {
82
+ const source = new URL(file.path, installed.root);
79
83
  const path = pathOf(file.target);
84
+ if (!within(source, installed.root)) {
85
+ copied.push({ path: file.path, state: 'outside' });
86
+ continue;
87
+ }
80
88
  if (path && !within(new URL(path, project), project)) {
81
89
  copied.push({ path: file.target, state: 'outside' });
82
90
  continue;
83
91
  }
84
92
  if (path) {
85
93
  named.add(path);
86
- folders.add(path.slice(0, path.lastIndexOf('/') + 1));
94
+ folders.add(folderOf(path));
87
95
  }
88
96
  const text = path && (await readFile(new URL(path, project), 'utf8').catch(() => undefined));
89
97
  if (!path || text === undefined)
90
98
  continue;
91
- const [, from, hash] = marker.exec(text) ?? [];
92
- const to = marker.exec(file.content)?.[1];
93
- if (!hash) {
94
- copied.push({ path, state: 'unknown', to });
95
- continue;
96
- }
97
- const differs = hash !== hashOf(file.content);
98
- const edited = hash !== hashOf(text);
99
- copied.push({ path, state: `${differs ? 'differs' : 'up to date'}${edited ? ', edited' : ''}`, from, to });
99
+ const state = stateOf(text, await readFile(source, 'utf8'));
100
+ const to = installed.version;
101
+ copied.push(state === 'unknown' ? { path, state, to } : { path, state, from: marker.exec(text)[1], to, ...(!ejected.has(folderOf(path)) && { item: name }) });
100
102
  }
101
103
  }
102
- // A marked file no item names any more: an item the registry dropped, which the project deletes.
104
+ // A marked file no item names any more: an item the package dropped, which the project deletes.
103
105
  for (const folder of folders) {
104
106
  const entries = await readdir(new URL(folder || './', project), { withFileTypes: true }).catch(() => []);
105
107
  for (const entry of entries) {
@@ -113,35 +115,142 @@ export const upgradeCheck = async (project) => {
113
115
  }
114
116
  return copied;
115
117
  };
116
- const sentence = ({ path, state, from = '', to = 'unknown' }, own) => ({
118
+ const sentence = ({ path, state, from = '', to = 'unknown', item }) => ({
117
119
  'up to date': `${path} is up to date.`,
118
- 'up to date, edited': `${path} is up to date, and edited since it was copied.`,
119
- differs: `${path} differs from ${own ? 'the copy in the registry the installed mithaq ships' : "the installed mithaq-admin's copy"} (built at ${to.slice(0, 12)}), and is not edited since it was copied from ${from.slice(0, 12)}. A hash shows a difference, not which side is newer: diff the two${own ? '' : ', or rebuild mithaq-admin,'} before replacing it.`,
120
- 'differs, edited': `${path} differs from ${own ? 'the copy in the registry the installed mithaq ships' : "the installed mithaq-admin's copy"} (built at ${to.slice(0, 12)}), and is edited since it was copied from ${from.slice(0, 12)}: diff the two and merge by hand.`,
121
- unknown: `${path} carries no mithaq-admin marker on its last line, so nothing says what it was copied from.`,
122
- outside: `${path} resolves outside this project or the registry, so it was not read.`,
123
- removed: `${path} was copied from mithaq-admin at ${from.slice(0, 12)}, and no item of the installed registry names it any more: it was removed from the registry, so delete it.`,
120
+ 'up to date, edited': `${path} is up to date, and edited since it was ejected.`,
121
+ differs: `${path} differs from the installed mithaq-admin's file (${to}), and is not edited since it was ejected from ${from.slice(0, 12)}. A hash shows a difference, not which side is newer: diff the two before replacing it, or ${item ? `delete it and run \`mithaq eject ${item}\` to take the package's` : 'delete it to draw the package\'s'}.`,
122
+ 'differs, edited': `${path} differs from the installed mithaq-admin's file (${to}), and is edited since it was ejected from ${from.slice(0, 12)}: diff the two and merge by hand.`,
123
+ unknown: `${path} carries no mithaq-admin marker on its last line, so nothing says what it was ejected from.`,
124
+ outside: `${path} resolves outside this project or the package, so it was not read.`,
125
+ removed: `${path} was ejected from mithaq-admin ${from.slice(0, 12)}, and no item of the installed package names it any more: it was removed from the package, so delete it.`,
124
126
  })[state];
125
- /** `mithaq upgrade --check`: one line per copied file, and 1 where any differs. It never writes. */
127
+ const noComponents = 'No components.json here, so nothing says where mithaq-admin files go.';
128
+ const noPackage = 'No mithaq-admin is installed here.';
129
+ /** `mithaq upgrade --check`: one line per file the project holds, and 1 where any differs. It never writes. */
126
130
  export const run = async (project) => {
127
131
  if (!existsSync(new URL('components.json', project))) {
128
- console.log('No components.json here, so nothing was copied with `shadcn add`.');
132
+ console.log(noComponents);
129
133
  return 0;
130
134
  }
131
135
  const copied = await upgradeCheck(project);
132
136
  if (!copied) {
133
- console.log(`No mithaq-admin registry to check the copies against: none is installed here, and the installed mithaq ships no r/ at ${fileURLToPath(registry)}.`);
137
+ console.log(`${noPackage} Nothing to check the files against.`);
134
138
  return 0;
135
139
  }
136
- const own = registryOf(project)?.href === registry.href;
137
140
  if (!copied.length)
138
141
  console.log('Nothing from mithaq-admin here.');
139
142
  for (const one of copied)
140
- console.log(sentence(one, own));
143
+ console.log(sentence(one));
141
144
  const differing = copied.filter((one) => one.state.startsWith('differs')).length;
142
145
  if (differing) {
143
- console.error(`${differing} ${differing === 1 ? 'file differs' : 'files differ'} from ${own ? 'the registry the installed mithaq ships' : 'the installed mithaq-admin'}.`);
146
+ console.error(`${differing} ${differing === 1 ? 'file differs' : 'files differ'} from the installed mithaq-admin.`);
144
147
  return 1;
145
148
  }
146
149
  return 0;
147
150
  };
151
+ /**
152
+ * `mithaq eject <item>`: writes the item's own files where the project keeps them, each byte for byte with a marker,
153
+ * and never over a file already there. It copies no other item and installs nothing: the package serves every
154
+ * sibling, so it only names a primitive or an npm package the project lacks.
155
+ */
156
+ export const eject = async (project, name) => {
157
+ if (!existsSync(new URL('components.json', project))) {
158
+ console.error(noComponents);
159
+ return 1;
160
+ }
161
+ const pathOf = await targetsOf(project);
162
+ const installed = await installedOf(project);
163
+ if (!installed) {
164
+ console.error(`${noPackage} Install it at the version of mithaq, then eject.`);
165
+ return 1;
166
+ }
167
+ const wanted = `mithaq-${name.replace(/\.tsx?$/, '').replace(/^mithaq-/, '')}`;
168
+ const item = installed.items.find((one) => one.name === wanted);
169
+ if (!item) {
170
+ console.error(`mithaq-admin has no item "${name}". Its items are: ${installed.items.map((one) => one.name).join(', ')}.`);
171
+ return 1;
172
+ }
173
+ const files = item.files.map((file) => ({ source: new URL(file.path, installed.root), path: pathOf(file.target), target: file.target }));
174
+ const refused = files.filter(({ source, path }) => !within(source, installed.root) || !path || !within(new URL(path, project), project));
175
+ if (refused.length) {
176
+ console.error(`${refused.map((one) => one.target).join(', ')} resolves outside this project or the package, so nothing was written.`);
177
+ return 1;
178
+ }
179
+ const ejected = ejectedFolders(installed.items, pathOf);
180
+ for (const { source, path } of files) {
181
+ const at = new URL(path, project);
182
+ const text = await readFile(source, 'utf8');
183
+ await mkdir(new URL('./', at), { recursive: true });
184
+ // `wx` fails on a file already there, so an eject never overwrites, whatever raced it.
185
+ const body = bodyOf(text);
186
+ const written = await writeFile(at, `${body}// mithaq-admin ${installed.version} ${sha(body)}\n`, { flag: 'wx' }).then(() => true, (error) => {
187
+ if (error.code === 'EEXIST')
188
+ return false;
189
+ throw error;
190
+ });
191
+ // A file the project always holds (the host, storage, a step) is never deleted: the app needs it.
192
+ const drawn = ejected.has(folderOf(path)) ? '; delete it to draw mithaq-admin\'s again' : '';
193
+ console.log(written ? `${path} is ejected. Edit it in place${drawn}.` : `${path} is already ejected, so it was left as it is.`);
194
+ }
195
+ for (const primitive of item.registryDependencies ?? []) {
196
+ const path = pathOf(`@ui/${primitive}.tsx`);
197
+ if (path && !existsSync(new URL(path, project)))
198
+ console.log(`${path} is missing: run shadcn add ${primitive}`);
199
+ }
200
+ const require = createRequire(new URL('package.json', project));
201
+ for (const dependency of item.dependencies ?? []) {
202
+ const npm = /^(@?[^@]+)/.exec(dependency)[1];
203
+ try {
204
+ require.resolve(npm);
205
+ }
206
+ catch (error) {
207
+ if (error.code === 'MODULE_NOT_FOUND')
208
+ console.log(`${npm} is not installed: install ${dependency}`);
209
+ }
210
+ }
211
+ return 0;
212
+ };
213
+ /**
214
+ * `mithaq eject --prune`: deletes every marked file in the folders pieces are ejected into whose text still hashes
215
+ * to its marker, so the package draws it again. Never an edited, unmarked or `~/` file.
216
+ */
217
+ export const prune = async (project) => {
218
+ if (!existsSync(new URL('components.json', project))) {
219
+ console.error(noComponents);
220
+ return 1;
221
+ }
222
+ const pathOf = await targetsOf(project);
223
+ const installed = await installedOf(project);
224
+ if (!installed) {
225
+ console.error(noPackage);
226
+ return 1;
227
+ }
228
+ // The package's file at each path, whose leading comments a copy `shadcn add` made may hold and no more.
229
+ const sources = new Map(installed.items.flatMap((item) => item.files.flatMap((file) => {
230
+ const path = pathOf(file.target);
231
+ return path ? [[path, new URL(file.path, installed.root)]] : [];
232
+ })));
233
+ const leadOf = (text) => leading.exec(bodyOf(text))[0];
234
+ for (const folder of ejectedFolders(installed.items, pathOf)) {
235
+ if (!within(new URL(folder, project), project))
236
+ continue;
237
+ const entries = await readdir(new URL(folder, project), { withFileTypes: true }).catch(() => []);
238
+ for (const entry of entries) {
239
+ if (!entry.isFile())
240
+ continue;
241
+ const path = `${folder}${entry.name}`;
242
+ const text = await readFile(new URL(path, project), 'utf8');
243
+ const [, from, hash] = marker.exec(text) ?? [];
244
+ if (!hash || hash !== hashFrom(from)(text))
245
+ continue;
246
+ // A commit's hash leaves leading comments out, so a pragma written above one is an edit the hash cannot see.
247
+ const source = sources.get(path);
248
+ const lead = leadOf(text);
249
+ if (madeByShadcn(from) && lead && lead !== (source && within(source, installed.root) ? leadOf(await readFile(source, 'utf8').catch(() => '')) : ''))
250
+ continue;
251
+ await rm(new URL(path, project));
252
+ console.log(`${path} was not edited since it was ejected, so it is deleted and mithaq-admin draws its own.`);
253
+ }
254
+ }
255
+ return 0;
256
+ };
@@ -7,17 +7,35 @@ export declare const draftOptions: (request: Request) => {
7
7
  autosave?: boolean | undefined;
8
8
  tenant?: string | undefined;
9
9
  };
10
+ /**
11
+ * The largest request body a plug reads, which a project names as `maxBodyBytes`: room for a
12
+ * json field's `defaultJsonMaxBytes` written in any script. A file never comes this way.
13
+ */
14
+ export declare const defaultMaxBodyBytes: number;
15
+ /**
16
+ * The body, or the answer that refuses it: 413 once it passes `limit` bytes, and 499 to a client
17
+ * that went away mid-body, which is no fault of the server's. Counted as it arrives, since a
18
+ * `content-length` may be absent or wrong, and the rest is never read.
19
+ */
20
+ export declare const boundedBody: (request: Request, limit: number) => Promise<Blob | Response>;
21
+ /** Refused by name, since core holds no limit of its own. */
22
+ export declare const assertMaxBodyBytes: (owner: string, value: unknown) => void;
23
+ /** Refused by name, as `auth.onError` and `storage.onError` are: core reports no fault of its own. */
24
+ export declare const assertOnError: (owner: string, value: unknown) => void;
25
+ export declare const tooLarge: (limit: number) => Response;
10
26
  export type FetchHttpOptions = {
27
+ /** The largest body a call takes, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required. */
28
+ maxBodyBytes: number;
11
29
  /**
12
30
  * Builds the context every step of a served call reads, from the request and the actor.
13
31
  * The plug reads no header or body key into it; a throw is answered 500.
14
32
  */
15
33
  context?: (request: Request, actor: unknown) => Promise<Record<string, unknown>> | Record<string, unknown>;
16
- /** Handed every error that is not an `AsasError`, before the 500 goes out with its message replaced. Defaults to `console.error`. */
17
- onError?: (error: unknown) => void;
34
+ /** Handed every error that is not an `AsasError`, before the 500 goes out with its message replaced. Required. */
35
+ onError: (error: unknown) => void;
18
36
  };
19
37
  /**
20
38
  * The default HTTP plug: `POST /api/<kayan>/<operation>` with a JSON body, over
21
39
  * `Request` and `Response` alone. The runner parses each operation's input.
22
40
  */
23
- export declare const fetchHttp: ({ context, onError }?: FetchHttpOptions) => <View>(operations: OperationTable<View>) => HttpPlug;
41
+ export declare const fetchHttp: (options: FetchHttpOptions) => <View>(operations: OperationTable<View>) => HttpPlug;
@@ -1,4 +1,4 @@
1
- import { AfterCommitError, AsasError, afterCommitHeader, codes } from '../runner/errors.js';
1
+ import { AfterCommitError, AsasError, afterCommitHeader, codes, retryConflict } from '../runner/errors.js';
2
2
  // The walk enters objects, arrays and the wrappers `unwrap()` opens, and nothing else.
3
3
  const datesFromJson = (value, schema) => {
4
4
  if (schema.type === 'date' && typeof value === 'string') {
@@ -40,76 +40,139 @@ export const draftOptions = (request) => {
40
40
  ...(query.has('tenant') ? { tenant: query.get('tenant') } : {}),
41
41
  };
42
42
  };
43
+ /**
44
+ * The largest request body a plug reads, which a project names as `maxBodyBytes`: room for a
45
+ * json field's `defaultJsonMaxBytes` written in any script. A file never comes this way.
46
+ */
47
+ export const defaultMaxBodyBytes = 4 * 1024 * 1024;
48
+ /**
49
+ * The body, or the answer that refuses it: 413 once it passes `limit` bytes, and 499 to a client
50
+ * that went away mid-body, which is no fault of the server's. Counted as it arrives, since a
51
+ * `content-length` may be absent or wrong, and the rest is never read.
52
+ */
53
+ export const boundedBody = async (request, limit) => {
54
+ const reader = request.body?.getReader();
55
+ const refused = () => {
56
+ // Not awaited: a clone's cancel settles only once its twin is cancelled too, which the
57
+ // caller does after this returns.
58
+ reader?.cancel().catch(() => { });
59
+ return tooLarge(limit);
60
+ };
61
+ if (Number(request.headers.get('content-length')) > limit)
62
+ return refused();
63
+ const chunks = [];
64
+ let size = 0;
65
+ for (;;) {
66
+ let read;
67
+ try {
68
+ read = (await reader?.read()) ?? { done: true, value: undefined };
69
+ }
70
+ catch (thrown) {
71
+ if (request.signal.aborted)
72
+ return new Response(null, { status: 499 });
73
+ throw thrown;
74
+ }
75
+ if (read.done)
76
+ return new Blob(chunks);
77
+ size += read.value.byteLength;
78
+ if (size > limit)
79
+ return refused();
80
+ chunks.push(read.value);
81
+ }
82
+ };
83
+ /** Refused by name, since core holds no limit of its own. */
84
+ export const assertMaxBodyBytes = (owner, value) => {
85
+ if (!Number.isSafeInteger(value) || value < 1) {
86
+ throw new Error(`${owner} names no "maxBodyBytes", the largest request body in bytes it reads, as a whole number above 0. Name the project's own, such as defaultMaxBodyBytes from mithaq.`);
87
+ }
88
+ };
89
+ /** Refused by name, as `auth.onError` and `storage.onError` are: core reports no fault of its own. */
90
+ export const assertOnError = (owner, value) => {
91
+ if (typeof value !== 'function') {
92
+ throw new Error(`${owner} names no "onError" function, which it hands every error that is no refusal before it answers 500. Name the project's own, such as console.error.`);
93
+ }
94
+ };
95
+ export const tooLarge = (limit) => Response.json({ code: codes[413], message: `The request body holds at most ${limit} bytes.` }, { status: 413 });
43
96
  /**
44
97
  * The default HTTP plug: `POST /api/<kayan>/<operation>` with a JSON body, over
45
98
  * `Request` and `Response` alone. The runner parses each operation's input.
46
99
  */
47
- export const fetchHttp = ({ context, onError = console.error } = {}) => (operations) => {
48
- const table = new Map(operations.map((one) => [`${one.kayan}/${one.name}`, one]));
49
- return {
50
- name: 'fetch',
51
- // The operation table is the routes, already published as `asas.operations`.
52
- router: undefined,
53
- handle: async (request, actor) => {
54
- if (request.method !== 'POST')
55
- return null;
56
- // Two segments under `/api` and no more, so another plug's three-segment prefix
57
- // falls through. `null`, not a 404, so a host keeps its own routes.
58
- const [api, kayan, name, ...rest] = new URL(request.url).pathname.split('/').slice(1);
59
- if (api !== 'api' || !kayan || !name || rest.length > 0)
60
- return null;
61
- const operation = table.get(`${kayan}/${name}`);
62
- if (!operation)
63
- return null;
64
- const body = await request.text();
65
- // Keyless: refused before any operation, and the key names an operation's refusal.
66
- const undecodable = (message) => Response.json({ code: codes[400], message }, { status: 400 });
67
- let input = {};
68
- if (body) {
69
- try {
70
- input = JSON.parse(body);
71
- }
72
- catch {
73
- return undecodable('The request body is not JSON.');
74
- }
75
- // The runner reads fields off the input, so a body of `null` or `1` would be a
76
- // 500 a caller provoked; every caller-provoked refusal is a 4xx.
77
- if (input === null || typeof input !== 'object' || Array.isArray(input)) {
78
- return undecodable('The request body is not a JSON object.');
79
- }
80
- }
81
- try {
82
- // Resolved after the match, so a host's own routes cost no session lookup.
83
- const decoded = decodeJson(input, operation.input);
84
- const who = await actor();
85
- const result = await operation.call(decoded, { actor: who, context: await context?.(request, who), ...draftOptions(request) });
86
- // Only a write says so, because the browser clears its banner on this value alone.
87
- return Response.json(result, operation.effect === 'read' ? undefined : { headers: { [afterCommitHeader]: 'ok' } });
88
- }
89
- catch (thrown) {
90
- if (thrown instanceof AfterCommitError) {
100
+ export const fetchHttp = (options) => {
101
+ // Read off `options?`, since a JavaScript caller may hand nothing, and is refused by name.
102
+ assertMaxBodyBytes('fetchHttp', options?.maxBodyBytes);
103
+ assertOnError('fetchHttp', options.onError);
104
+ const { context, onError, maxBodyBytes } = options;
105
+ return (operations) => {
106
+ const table = new Map(operations.map((one) => [`${one.kayan}/${one.name}`, one]));
107
+ return {
108
+ name: 'fetch',
109
+ // The operation table is the routes, already published as `asas.operations`.
110
+ router: undefined,
111
+ handle: async (request, actor) => {
112
+ if (request.method !== 'POST')
113
+ return null;
114
+ // Two segments under `/api` and no more, so another plug's three-segment prefix
115
+ // falls through. `null`, not a 404, so a host keeps its own routes.
116
+ const [api, kayan, name, ...rest] = new URL(request.url).pathname.split('/').slice(1);
117
+ if (api !== 'api' || !kayan || !name || rest.length > 0)
118
+ return null;
119
+ const operation = table.get(`${kayan}/${name}`);
120
+ if (!operation)
121
+ return null;
122
+ const read = await boundedBody(request, maxBodyBytes);
123
+ if (read instanceof Response)
124
+ return read;
125
+ const body = await read.text();
126
+ // Keyless: refused before any operation, and the key names an operation's refusal.
127
+ const undecodable = (message) => Response.json({ code: codes[400], message }, { status: 400 });
128
+ let input = {};
129
+ if (body) {
91
130
  try {
92
- onError?.(thrown.cause);
131
+ input = JSON.parse(body);
93
132
  }
94
133
  catch {
95
- // Reporting cannot change a committed result.
134
+ return undecodable('The request body is not JSON.');
135
+ }
136
+ // The runner reads fields off the input, so a body of `null` or `1` would be a
137
+ // 500 a caller provoked; every caller-provoked refusal is a 4xx.
138
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
139
+ return undecodable('The request body is not a JSON object.');
96
140
  }
97
- return Response.json(thrown.result, { headers: { [afterCommitHeader]: 'failed' } });
98
- }
99
- if (thrown instanceof AsasError) {
100
- // The cause is never serialized: a conflict's driver error names the column and
101
- // value the refusal's message withholds.
102
- return Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
103
141
  }
104
142
  try {
105
- onError?.(thrown);
143
+ // Resolved after the match, so a host's own routes cost no session lookup.
144
+ const decoded = decodeJson(input, operation.input);
145
+ const who = await actor();
146
+ const result = await operation.call(decoded, { actor: who, context: await context?.(request, who), ...draftOptions(request) });
147
+ // Only a write says so, because the browser clears its banner on this value alone.
148
+ return Response.json(result, operation.effect === 'read' ? undefined : { headers: { [afterCommitHeader]: 'ok' } });
106
149
  }
107
- catch {
108
- // Deliberately empty: reporting may not change what is reported.
150
+ catch (thrown) {
151
+ if (thrown instanceof AfterCommitError) {
152
+ try {
153
+ onError(thrown.cause);
154
+ }
155
+ catch {
156
+ // Reporting cannot change a committed result.
157
+ }
158
+ return Response.json(thrown.result, { headers: { [afterCommitHeader]: 'failed' } });
159
+ }
160
+ thrown = retryConflict(thrown);
161
+ if (thrown instanceof AsasError) {
162
+ // The cause is never serialized: a conflict's driver error names the column and
163
+ // value the refusal's message withholds.
164
+ return Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
165
+ }
166
+ try {
167
+ onError(thrown);
168
+ }
169
+ catch {
170
+ // Deliberately empty: reporting may not change what is reported.
171
+ }
172
+ // Not an `AsasError` is the project's own fault, so its message is replaced.
173
+ return Response.json({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' }, { status: 500 });
109
174
  }
110
- // Not an `AsasError` is the project's own fault, so its message is replaced.
111
- return Response.json({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' }, { status: 500 });
112
- }
113
- },
175
+ },
176
+ };
114
177
  };
115
178
  };
@@ -16,9 +16,11 @@ export type OrpcHttpOptions = {
16
16
  context?: (request: Request, actor: unknown) => Promise<OrpcContext> | OrpcContext;
17
17
  /**
18
18
  * Handed every error that is not an `AsasError`, before it is rethrown with its message replaced.
19
- * A host's own deliberate `ORPCError` reaches it too, so a host logging only faults filters on the code.
19
+ * A host's own deliberate `ORPCError` reaches it too, so a host logging only faults filters on the code. Required.
20
20
  */
21
- onError?: (error: unknown) => void;
21
+ onError: (error: unknown) => void;
22
+ /** The largest body a call takes, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required. */
23
+ maxBodyBytes: number;
22
24
  };
23
25
  type OrpcProcedure<Method> = Method extends (...args: infer Args) => Promise<infer Answer> ? Procedure<OrpcContext, OrpcContext, Schema<Args[0], Args[0]>, Schema<Answer, Answer>, Record<never, never>, Record<never, never>> : never;
24
26
  /** The router, typed from the operation table: kayan, then operation. `RouterClient` over it needs no generated code. */
@@ -31,7 +33,7 @@ export type OrpcRouter<View> = {
31
33
  * The oRPC plug: one procedure per operation, served over both the typed RPC protocol
32
34
  * and plain HTTP. Needs `@orpc/server` and `@orpc/openapi`.
33
35
  */
34
- export declare const orpcHttp: ({ rpc, api, context, onError, }?: OrpcHttpOptions) => <View>(operations: OperationTable<View>) => {
36
+ export declare const orpcHttp: (options: OrpcHttpOptions) => <View>(operations: OperationTable<View>) => {
35
37
  name: string;
36
38
  router: OrpcRouter<View>;
37
39
  /**