@drhomet/mithaq 0.1.1 → 0.2.1

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 +974 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +283 -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 +1094 -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
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 OpenStatus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # mithaq
2
+
3
+ [![npm](https://img.shields.io/npm/v/@drhomet/mithaq)](https://www.npmjs.com/package/@drhomet/mithaq)
4
+
5
+ mithaq is a TypeScript language for declaring a backend and its admin once, and building the database, API, validation, access rules and admin screens from that one declaration.
6
+
7
+ The package is `@drhomet/mithaq`. It is pre-1.0, so the API may still change between releases.
8
+
9
+ The name is Arabic for a covenant. The other words in the API are Arabic too, and each is defined where it first appears below.
10
+
11
+ ## Why it exists
12
+
13
+ - **One declaration drives everything.** A kayan's fields, kinds and rules become the Postgres table (through drizzle), the input schema (through zod), the HTTP operations and the admin forms and lists. Nothing is declared twice, so nothing drifts.
14
+ - **The vocabulary is frozen.** The kinds, rules and targets mithaq ships change only by the maintainer's explicit decision. A project can also seal its own language in a signed `mithaq.lock`. `mithaq lock --check`, which the apps run before `dev`, `build` and `preview`, then fails when the vocabulary no longer matches the lock. Signing a change takes a code shown in a popup, never in the command's output, so a script or an agent running `mithaq lock` cannot approve it.
15
+ - **Tenant isolation is enforced in core.** A kayan that places the tenant field belongs to one organization. Core adds the tenant scope to every operation and combines it with the project's own access with AND, so no access rule can widen it. Writes land in the active organization, and `unique` rules and index groups are built per organization.
16
+ - **Defaults are explicit.** The project names every default it uses (`defaultOperations`, `defaultFields`, `defaultAuthorize`, the limits) in its own code. Core does not fall back to a default of its own: a configuration that leaves a required one out is a type error or a refusal from `buildAsas`.
17
+ - **The admin is your code when you want it.** The screens are shadcn components the project imports from `mithaq-admin` and draws with its own primitives; `mithaq eject` puts any one of them into the project to edit like any other file.
18
+
19
+ ## Quick start
20
+
21
+ ```sh
22
+ bun create mithaq my-app --full
23
+ ```
24
+
25
+ This creates a Bun workspace of the template's three steps, the language (`packages/mithaq`), a package per feature (`packages/features/*`) and the asas that builds them (`packages/asas`), and two apps: the tenant app (`apps/asas-tanstack`), which serves the dashboard and the portal, and the backoffice console (`apps/asas-backoffice`), for staff who work across every organization. It writes a `.env` with fresh secrets and runs `bun install`. `--full` takes every add-on, the whole worked example; without it the project is minimal, auth and a `tags` example, and `--with roles,courses` picks add-ons.
26
+
27
+ Then, with PostgreSQL 15 or later serving the database that `DATABASE_URL` names in `packages/asas/.env`:
28
+
29
+ ```sh
30
+ cd my-app
31
+ cd packages/asas
32
+ # push the schema, make the dev admin and fill its organization, development only
33
+ bun run dev:setup --seed
34
+ cd ../..
35
+ bun run --cwd apps/asas-tanstack dev # the tenant app, http://localhost:5173
36
+ bun run --cwd apps/asas-backoffice dev # in a second terminal: the backoffice console, http://localhost:5174
37
+ ```
38
+
39
+ Sign in to both apps with `DEV_ADMIN_EMAIL` and `DEV_ADMIN_PASSWORD` from `packages/asas/.env`: the dev admin owns "Dev admin's workspace", which `--seed` filled, and is the backoffice's `super_admin`.
40
+
41
+ In production, `connect()` does not push the schema; it checks the migration files instead. [Running in production](https://github.com/drhomet/mithaq/blob/main/templates/asas/README.md#running-in-production) explains how to apply them.
42
+
43
+ ## A small example
44
+
45
+ A language comes from `defineMithaq`. Its arkan (singular rukn, "pillar") are the pluggable parts that bring kinds and rules; `base` brings the shipped ones. Its targets are what each declaration is built into.
46
+
47
+ ```ts
48
+ // src/mithaq.ts
49
+ import {
50
+ base, defaultAuthorize, defaultBulkLimit, defaultDepth, defaultJoinLimit, defaultLimit, defaultMaxLimit,
51
+ defineMithaq, drizzleAdapter, drizzleKinds, drizzleRules, drizzleIdKinds, serviceTarget, zodKinds, zodRules, zodIdKinds, zodTarget,
52
+ } from 'mithaq'
53
+
54
+ export const { defineKayan, buildAsas } = defineMithaq({
55
+ locales: ['en', 'ar'] as const,
56
+ arkan: [base],
57
+ targets: {
58
+ drizzle: drizzleAdapter({ connectionString: process.env.DATABASE_URL!, kinds: drizzleKinds, rules: drizzleRules, idKinds: drizzleIdKinds }),
59
+ zod: zodTarget({ kinds: zodKinds, rules: zodRules, idKinds: zodIdKinds }),
60
+ service: serviceTarget({
61
+ authorize: defaultAuthorize,
62
+ depth: defaultDepth,
63
+ defaultLimit,
64
+ maxLimit: defaultMaxLimit,
65
+ joinLimit: defaultJoinLimit,
66
+ bulkLimit: defaultBulkLimit,
67
+ store: 'drizzle',
68
+ schema: 'zod',
69
+ }),
70
+ },
71
+ })
72
+ ```
73
+
74
+ A kayan ("entity") is one data type: its fields, their kinds and rules, its access and its operations. Every label carries one string per locale the language declares, and a missing one is a type error.
75
+
76
+ ```ts
77
+ // src/tags.ts
78
+ import { defaultFields, defaultOperations } from 'mithaq'
79
+ import { defineKayan } from './mithaq.ts'
80
+
81
+ export const tags = defineKayan({
82
+ name: 'tags',
83
+ access: { read: () => true },
84
+ targets: { service: { operations: defaultOperations } },
85
+ fields: {
86
+ ...defaultFields,
87
+ name: {
88
+ kind: 'text',
89
+ label: { en: 'Name', ar: 'الاسم' },
90
+ rules: { required: true, unique: true, trim: true, maxLength: 60 },
91
+ },
92
+ },
93
+ })
94
+ ```
95
+
96
+ `buildAsas` builds the asas ("foundation"), the runtime:
97
+
98
+ ```ts
99
+ // src/asas.ts
100
+ import { defaultMaxBodyBytes, fetchHttp } from 'mithaq'
101
+ import { buildAsas } from './mithaq.ts'
102
+ import { tags } from './tags.ts'
103
+
104
+ export const asas = buildAsas({ kayan: [tags], http: [fetchHttp({ maxBodyBytes: defaultMaxBodyBytes, onError: console.error })] })
105
+ ```
106
+
107
+ `asas.handler(request)` answers a `Request` with a `Response`; mount it under `/api` in any server. With `fetchHttp`, `POST /api/tags/create` with a JSON body creates a tag, and `POST /api/tags/find` lists them. In development, `asas.connect()` pushes the schema to Postgres. A kayan that declares no access answers only the admin the build names, so this one opens reads to everyone.
108
+
109
+ To make a kayan belong to one organization, add the tenant target to the language and place its field:
110
+
111
+ ```ts
112
+ // in defineMithaq's targets:
113
+ tenant: betterAuthTenant({ relationTo: 'organization', onDelete: 'restrict', newRows: 'active', organizations: 'many' }),
114
+
115
+ // in a kayan's fields, with `tenant` taken from the language's `targets`:
116
+ organization: tenant.field,
117
+ ```
118
+
119
+ `unique: true` on that kayan is then unique within each organization, and `indexes: [{ within: 'organization', unique: [['code', 'term']] }]` declares a compound one. [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md) has the full rules.
120
+
121
+ ## What's in the box
122
+
123
+ - **Store:** Postgres through drizzle (`drizzleAdapter`), with a schema push in development and migration files written by `mithaq migrate` for production.
124
+ - **Validation:** zod schemas derived from every kayan (`zodTarget`). A field can carry its own zod shape instead.
125
+ - **Runtime:** the operation runner (`serviceTarget`) with the default operations (create, update, delete, find, findByID, count, updateMany, deleteMany), steps around every write, an audit step, soft delete and bulk limits.
126
+ - **HTTP:** plain fetch routes (`fetchHttp`), or a typed client and OpenAPI through oRPC (`mithaq/orpc`).
127
+ - **Access:** role-based access with permission keys per operation and per field (`rbacTarget`, `betterAuthTarget` to keep roles in Better Auth's own tables, or `memberRoleTarget` for Better Auth's own member roles alone).
128
+ - **Tenancy:** `betterAuthTenant`, over Better Auth organizations.
129
+ - **Auth:** Better Auth (`betterAuth`) or local accounts (`local`), with `mithaq generate auth` writing the Better Auth kayan.
130
+ - **Versions and drafts:** the `versions` rukn keeps a row's past versions, with drafts, autosave and restore.
131
+ - **Files:** the `storage` rukn and the `upload` kind. The template ships adapters for local disk, Cloudflare R2, S3-compatible services and UploadThing as project files.
132
+ - **Rich text:** the `richText` rukn, over Lexical.
133
+ - **Admin screens:** `screenTarget` describes each screen; `mithaq generate screens` writes its routes into the host app, and the React components come from the `mithaq-admin` package, drawn with the app's own shadcn primitives, and a project ejects the ones it wants to own. `mithaq upgrade --check` compares the files it holds with the installed version.
134
+ - **Copy:** `mithaq generate messages` writes the project's labels and messages to one file per kayan, and refuses a label missing a locale.
135
+
136
+ `mithaq --help` lists every command.
137
+
138
+ ## Requirements
139
+
140
+ - npm, pnpm 10.6 or later, Yarn 1 or [Bun](https://bun.sh) 1.3, whichever runs `create mithaq`. Yarn 2 and later install a copy of mithaq for each workspace of a monorepo, which the build refuses, so a monorepo does not take them yet.
141
+ - Node.js 24 or later.
142
+ - PostgreSQL 15 or later.
143
+ - TypeScript, installed by the project.
144
+
145
+ ## Documentation
146
+
147
+ Each guide is the `README.md` of the folder it describes.
148
+
149
+ - [Working in a mithaq project](https://github.com/drhomet/mithaq/blob/main/templates/asas/README.md): getting started, where each file lives, adding a kayan, a feature or a step, and running in production. It ships in every created project.
150
+ - [The mithaq language](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/README.md): the language, kinds, rules, kayan, targets, the runner and access, and adding a rukn, a kind, a target or a configurable feature.
151
+ - [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md): per-organization kayan, what core enforces, and per-organization indexes.
152
+ - [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md): every command, flag and exit code.
153
+ - [Migrations in production](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/drizzle/README.md): `pending: 'refuse'` or `'run'`, the advisory lock, and applying migrations by hand.
154
+ - [The admin package](https://github.com/drhomet/mithaq/blob/main/packages/mithaq-admin/README.md): the screen components, ejecting one, `mithaq upgrade --check`, migrating a project that holds copies, and adding a registry item.
155
+ - [Contributing](https://github.com/drhomet/mithaq/blob/main/CONTRIBUTING.md): setting up, the checks, the repository layout, and sending a pull request.
156
+
157
+ ## Status and license
158
+
159
+ mithaq is pre-1.0. The template that `bun create mithaq` creates is the worked example, and every file there is commented.
160
+
161
+ MIT licensed; see [LICENSE](https://github.com/drhomet/mithaq/blob/main/LICENSE). mithaq includes code adapted from Payload and from openstatus's time picker, both MIT licensed; their notices are in [LICENSES/payload.txt](https://github.com/drhomet/mithaq/blob/main/LICENSES/payload.txt) and [LICENSES/openstatus-time-picker.txt](https://github.com/drhomet/mithaq/blob/main/LICENSES/openstatus-time-picker.txt).
@@ -111,6 +111,12 @@ type AdminAccessShapeMismatch<Auth> = Auth extends {
111
111
  export type Vocabulary = {
112
112
  catalog: readonly string[];
113
113
  statements: Readonly<Record<string, readonly string[]>>;
114
+ /**
115
+ * How many rows, across every organization, still name the auth kayan's row `id` through a
116
+ * relationship that restricts its delete. A provider route that deletes a person outside a
117
+ * transaction asks it first, since the foreign key would fail only after other rows went.
118
+ */
119
+ referenced: (id: string) => Promise<number>;
114
120
  };
115
121
  /** The build's auth: the admin kayan whose actor passes default access, and where failures are reported. */
116
122
  export type AuthConfig = {
@@ -121,6 +127,8 @@ export type AuthConfig = {
121
127
  };
122
128
  /** Handed a strategy that threw and an access target that could not answer, each as an `Error` with the cause. Required where any kayan carries auth. */
123
129
  onError: (error: unknown) => void;
130
+ /** The largest body an auth route reads, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required where any kayan carries auth. */
131
+ maxBodyBytes: number;
124
132
  };
125
133
  type PublicLocalUser<K extends Kayan, L extends Language, KayanList extends readonly Kayan[]> = Omit<Row<K['fields'], L, KayanList>, ProtectedLocalField>;
126
134
  /** One strategy per kayan carrying `auth`, typed by that provider's or that strategy's own actor. */
@@ -186,7 +194,7 @@ export declare const buildAsas: <const L extends Language, const KayanList exten
186
194
  auth?: Auth;
187
195
  /**
188
196
  * The HTTP plugs, asked in order, so the order decides who owns a prefix two claim. Required:
189
- * `fetchHttp()` takes no dependency; `orpcHttp()` first gives `asas.router` its typed client.
197
+ * `fetchHttp` takes no dependency; `orpcHttp` first gives `asas.router` its typed client.
190
198
  */
191
199
  http: readonly [(operations: OperationTable<Procedures<KayanList, GlobalList, Declared<KayanList, GlobalList>, L>>) => Http, ...((operations: OperationTable<Procedures<KayanList, GlobalList, Declared<KayanList, GlobalList>, L>>) => HttpPlug)[]];
192
200
  /** The project's own copy for the labels mithaq ships, as one resolver whose return shape mithaq declares. */
@@ -202,7 +210,7 @@ export declare const buildAsas: <const L extends Language, const KayanList exten
202
210
  statements: Readonly<Record<string, readonly string[]>>;
203
211
  /** Every built operation, flat. Its `procedures` view is a type, keyed by kayan then operation. */
204
212
  operations: OperationTable<Procedures<KayanList, GlobalList, Declared<KayanList, GlobalList>, L>>;
205
- /** The router the first plug built, which a typed client is built from; `undefined` for `fetchHttp()`. */
213
+ /** The router the first plug built, which a typed client is built from; `undefined` for `fetchHttp`. */
206
214
  router: Http['router'];
207
215
  /** Every target as the language passed it, so `asas.targets.drizzle.db` is drizzle. A screen also carries its projection under `client`. */
208
216
  targets: { [Name in keyof L['targets']]: L['targets'][Name] extends {
@@ -5,10 +5,11 @@ import { holdsVerificationSteps, isLocalAuthTarget, localAuthStrategy } from '..
5
5
  import { bulkSeals, outputOf, sealOf, storeSeals } from './runner/steps.js';
6
6
  import { running } from './runner/runtime.js';
7
7
  import { tenancy } from './runner/tenant.js';
8
- import { assertWhere, defaultAccess, defaultAuthorize, projectReadableFields } from './runner/write.js';
8
+ import { assertWhere, defaultAccess, defaultAuthorize, projectReadableFields, referencingIds, restrictingPointers } from './runner/write.js';
9
9
  import { combineWhere } from '../mithaq/where.js';
10
10
  import { bulk, read as readers, write } from '../service/index.js';
11
11
  import { invalid } from './runner/errors.js';
12
+ import { assertMaxBodyBytes, boundedBody } from './http/fetch.js';
12
13
  import { fieldlessOperation, keepOperationKeys } from '../rbac/steps.js';
13
14
  import { resolvedKayans } from '../mithaq/types.js';
14
15
  import { accessTargetOf, isAccess, isScreen, isService, isStorage, isStore, isTenant, isVersions, serviceKeys, serviceTargetOf } from '../mithaq/language.js';
@@ -16,6 +17,15 @@ import { screensOf } from '../screen/index.js';
16
17
  import { messagesOf } from '../mithaq/messages.js';
17
18
  import { resolve } from '../mithaq/resolve.js';
18
19
  import { recordStorage, resolveStorage, storageRoutes } from '../storage/storage.js';
20
+ const lowerCase = (value) => typeof value === 'string' ? value.toLowerCase() : Array.isArray(value) ? value.map(lowerCase) : value;
21
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ // The parse lower-cases a uuid, as Postgres answers one, but access and the steps before the
23
+ // transaction run ahead of it, so a JS comparison there must see the same spelling.
24
+ const lowerUuids = ({ kayan, input, bulk }) => {
25
+ const fields = (data) => Object.fromEntries(Object.entries(data).map(([key, value]) => [key, kayan.fields[key]?.kind === 'uuid' || kayan.fields[key]?.target?.fields.id?.kind === 'uuid' ? lowerCase(value) : value]));
26
+ // A bulk write's own keys are its query; a row's columns are under `data`.
27
+ return !isObject(input) ? input : bulk ? { ...input, ...(isObject(input.data) && { data: fields(input.data) }) } : fields(input);
28
+ };
19
29
  // A symbol: no schema of something a browser can send takes it, so a schema that names
20
30
  // the key answers with an issue at the key.
21
31
  const probe = Symbol('mithaq.operation.input');
@@ -50,13 +60,21 @@ singleUpdate, cap, read, kayans, of, audit, admin, authorize, catalog,
50
60
  /** True where the runner declares a context, which a served call must then carry. */
51
61
  contextDeclared,
52
62
  /** The most rows a served read of many may ask for, and one bulk write may match. */
53
- { maxLimit, bulkLimit }, tenant) => {
63
+ { maxLimit, bulkLimit }, tenant,
64
+ /** The fields of every kayan that keep a row of this one while they name it. */
65
+ pointers) => {
54
66
  // Bound, so a step cannot reach `transaction` or the adapter's own client, and sealed, so no
55
67
  // step writes a tenant-owned row outside the active organization.
68
+ const missing = ['upsert', 'sum'].filter((word) => typeof store[word] !== 'function');
69
+ if (missing.length)
70
+ throw new Error(`The store is missing required words: ${missing.join(', ')}.`);
56
71
  const writes = tenant.seal({
72
+ find: store.find.bind(store),
57
73
  insert: store.insert.bind(store),
58
74
  remove: store.remove.bind(store),
59
75
  update: store.update.bind(store),
76
+ upsert: store.upsert.bind(store),
77
+ sum: store.sum.bind(store),
60
78
  });
61
79
  const table = [];
62
80
  /** The bulk lists, held for the second pass: each needs a method the first builds. */
@@ -64,8 +82,10 @@ contextDeclared,
64
82
  // Options come from the plug alone, so nothing a client sends reaches a step as one.
65
83
  // On a runner that declares a context, a plug carrying none is refused before any step.
66
84
  const publish = (name, effect, input, method, bounded = false) => {
85
+ const bulk = kayan.operations[name].steps.some((step) => bulkSeals.includes(sealOf(step)));
67
86
  // The declared arity is what this widens across, which TypeScript asks two steps for.
68
- const called = method;
87
+ const normalized = ((given, options) => method(lowerUuids({ kayan, input: given, bulk }), options));
88
+ const called = normalized;
69
89
  table.push({
70
90
  kayan: kayan.name,
71
91
  name,
@@ -74,7 +94,7 @@ contextDeclared,
74
94
  input,
75
95
  call: (given, { actor, context, draft, autosave, tenant }) => {
76
96
  if (contextDeclared && !context) {
77
- throw new Error(`Kayan "${kayan.name}" operation "${name}" was served with no context, and this build's runner declares one. Give every http plug a context function: fetchHttp({ context }) or orpcHttp({ context }).`);
97
+ throw new Error(`Kayan "${kayan.name}" operation "${name}" was served with no context, and this build's runner declares one. Give every http plug a context function: fetchHttp({ context, maxBodyBytes: defaultMaxBodyBytes, onError: console.error }), or orpcHttp with the same options.`);
78
98
  }
79
99
  if (bounded && given && typeof given === 'object' && 'limit' in given) {
80
100
  const limit = given.limit;
@@ -85,7 +105,7 @@ contextDeclared,
85
105
  return called(given, { actor, ...(context ? { context } : {}), ...(draft ? { draft } : {}), ...(autosave ? { autosave } : {}), ...(tenant !== undefined ? { tenant } : {}) });
86
106
  },
87
107
  });
88
- return method;
108
+ return normalized;
89
109
  };
90
110
  const methods = Object.create(null);
91
111
  for (const [operation, declaration] of Object.entries(kayan.operations)) {
@@ -145,16 +165,20 @@ contextDeclared,
145
165
  enumerable: true,
146
166
  get: () => kayans(declaration.effect),
147
167
  });
168
+ if (declaration.each && !bulkSeals.includes(kind))
169
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" names "each", and only a bulk operation runs another on each row.`);
148
170
  // Built in the second pass, where every single-row write it delegates to exists.
149
171
  if (bulkSeals.includes(kind)) {
150
172
  bulkLists.push({ operation, declaration, tools, input });
151
173
  continue;
152
174
  }
153
- const run = write({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf: tenant.active });
175
+ const run = write({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf: tenant.active, pointers });
154
176
  methods[operation] = publish(operation, declaration.effect, input, run);
155
177
  }
156
178
  for (const { operation, declaration, tools, input } of bulkLists) {
157
- const single = declaration.effect === 'update' ? 'update' : 'delete';
179
+ const single = declaration.each ?? (declaration.effect === 'update' ? 'update' : 'delete');
180
+ if (!methods[single])
181
+ throw new Error(`Kayan "${kayan.name}" operation "${operation}" runs "${single}" on each row it matches, and the kayan has no operation "${single}".`);
158
182
  const run = bulk({
159
183
  kayan,
160
184
  declaration,
@@ -202,7 +226,7 @@ const refuseArkan = (language) => {
202
226
  export const buildAsas = (language, { kayan, global, auth, http, messages, statements: declaredStatements, }) => {
203
227
  // Refused before any work: core picks no plug of its own.
204
228
  if (!http?.length) {
205
- throw new Error('buildAsas was handed no "http", and core picks no plug of its own. Name the plugs: http: [fetchHttp()], or http: [orpcHttp(), fetchHttp()] for the typed client.');
229
+ throw new Error('buildAsas was handed no "http", and core picks no plug of its own. Name the plugs: http: [fetchHttp({ maxBodyBytes: defaultMaxBodyBytes, onError: console.error })], or orpcHttp with the same options first for the typed client.');
206
230
  }
207
231
  const globals = (global ?? []);
208
232
  const globalNames = new Set(globals.map((one) => one.name));
@@ -420,6 +444,20 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
420
444
  throw new Error(`Kayan "${betterAuthKayan.join('" and "')}" each carry Better Auth, and kayan "${owned.name}" belongs to the organization a session names. A build with a tenant-owned kayan carries one Better Auth kayan.`);
421
445
  }
422
446
  const tenant = tenancy({ language, assigned, onError: (error) => auth?.onError?.(error), collection: betterAuthKayan[0] });
447
+ // A project of one organization that came to hold two would split its people and rows between them
448
+ // without a word, so it does not start. Two rows are read, not counted, so every store can answer.
449
+ const refuseExtraTenants = async () => {
450
+ const target = language.targets.tenant;
451
+ if (!isTenant(target) || target.organizations === 'many')
452
+ return;
453
+ const tenants = resolvedByName.get(target.relationTo);
454
+ if (!tenants)
455
+ return;
456
+ const found = await buildTargets[tenants.store].find({ kayan: tenants.name, args: { limit: 2, page: 1, sort: { field: 'id', direction: 'asc' } } });
457
+ if (found.docs.length < 2)
458
+ return;
459
+ throw new Error(`The tenant target says organizations: '${target.organizations}', and the "${tenants.name}" table holds more than one. Set organizations to 'many' where the project sets it, or merge them into one first; a merge is a migration of the project's own.`);
460
+ };
423
461
  // The tenant scope goes under both authorizers, so replacing either cannot leave it out.
424
462
  const authorize = tenant.scoped(service.authorize === defaultAuthorize
425
463
  ? defaultAuthorize
@@ -509,12 +547,16 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
509
547
  admin,
510
548
  authorize,
511
549
  openTenant: tenant.open,
550
+ tenantOf: tenant.active,
512
551
  kayans: () => boundKayans('read'),
513
552
  of: boundOf('read'),
514
553
  audit: service.audit,
515
554
  catalog,
555
+ pickers: Object.fromEntries(resolved.flatMap((other) => Object.values(other.fields)
556
+ .filter((field) => field.kind !== 'join' && field.target?.name === one.name)
557
+ .map((field) => [`${other.name}.${field.name}`, field]))),
516
558
  });
517
- const { methods, table } = operations(one, store, schemas, singleUpdate, cap, read, (effect) => boundKayans(effect === 'read' ? 'read' : 'write'), (effect) => boundOf(effect === 'read' ? 'read' : 'write'), service.audit, admin, authorize, catalog, service.context !== undefined, service, { active: tenant.active, seal: (writes) => tenant.sealWrites({ writes, find: store.find.bind(store), kayanOf: (name) => resolvedByName.get(name) }) });
559
+ const { methods, table } = operations(one, store, schemas, singleUpdate, cap, read, (effect) => boundKayans(effect === 'read' ? 'read' : 'write'), (effect) => boundOf(effect === 'read' ? 'read' : 'write'), service.audit, admin, authorize, catalog, service.context !== undefined, service, { active: tenant.active, seal: (writes) => tenant.sealWrites({ writes, find: store.find.bind(store), kayanOf: (name) => resolvedByName.get(name) }) }, restrictingPointers(resolved, one));
518
560
  operationTable.push(...table);
519
561
  const bound = Object.freeze(methods);
520
562
  // Only what this kayan's own targets built for it; their configuration stays on `asas.targets`.
@@ -539,7 +581,7 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
539
581
  .filter((operation) => operation.kayan === one.name && operation.effect === effect)
540
582
  .map((operation) => operation.name);
541
583
  const operations = {};
542
- // Platform staff in the platform app read a tenant-owned kayan across every organization,
584
+ // Backoffice staff in the backoffice app read a tenant-owned kayan across every organization,
543
585
  // so its read is reported as they make it; anyone else is answered in the active one.
544
586
  const reach = effect === 'read' && one.tenant && !one.single
545
587
  ? await tenant.open({ actor: actorForAccess, reach: 'all', kayan: one }).catch(() => undefined)
@@ -573,15 +615,24 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
573
615
  };
574
616
  // Precedence is the order the build lists its kayan: the loop takes the first non-null actor.
575
617
  if (authenticating.length && typeof auth?.onError !== 'function') {
576
- throw new Error(`buildAsas was handed no "auth.onError", and kayan "${authenticating[0].name}" carries auth. Core reports no failed strategy of its own: name the project's own, such as auth: { onError: console.error }.`);
618
+ throw new Error(`buildAsas was handed no "auth.onError", and kayan "${authenticating[0].name}" carries auth. Core reports no failed strategy of its own: name the project's own, such as auth: { onError: console.error, maxBodyBytes: defaultMaxBodyBytes }.`);
577
619
  }
620
+ if (authenticating.length)
621
+ assertMaxBodyBytes('auth', auth.maxBodyBytes);
578
622
  const onError = (error) => auth.onError(error);
579
623
  const strategies = (() => {
580
624
  const list = [];
581
625
  // Without this freeze a `roles` function reassigning `v.catalog` would be seen by a
582
626
  // `server` factory, and the target and the provider would disagree.
583
- const vocabulary = Object.freeze({ catalog: catalog, statements });
584
627
  for (const one of authenticating) {
628
+ const own = resolvedByName.get(one.name);
629
+ const pointers = restrictingPointers(resolved, own);
630
+ const ownStore = buildTargets[own.store];
631
+ const vocabulary = Object.freeze({
632
+ catalog: catalog,
633
+ statements,
634
+ referenced: async (id) => [...(await referencingIds({ id, pointers, store: ownStore })).values()].reduce((sum, ids) => sum + ids.size, 0),
635
+ });
585
636
  const declaration = one.auth;
586
637
  if (!isAuthDeclaration(declaration)) {
587
638
  list.push(declaration);
@@ -672,7 +723,7 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
672
723
  statements: statements,
673
724
  /** Every built operation, flat. Its `procedures` view is a type, keyed by kayan then operation. */
674
725
  operations: operationTable,
675
- /** The router the first plug built, which a typed client is built from; `undefined` for `fetchHttp()`. */
726
+ /** The router the first plug built, which a typed client is built from; `undefined` for `fetchHttp`. */
676
727
  router: plugs[0]?.router,
677
728
  /** Every target as the language passed it, so `asas.targets.drizzle.db` is drizzle. A screen also carries its projection under `client`. */
678
729
  targets: Object.fromEntries(Object.entries(buildTargets).map(([name, target]) => [name, isScreen(target) ? { ...target, client: screens[name] } : target])),
@@ -697,15 +748,9 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
697
748
  // logged by the provider as its own 500.
698
749
  if (!request.body)
699
750
  return strategy.handler(request);
700
- let body;
701
- try {
702
- body = await request.arrayBuffer();
703
- }
704
- catch (thrown) {
705
- if (request.signal.aborted)
706
- return new Response(null, { status: 499 });
707
- throw thrown;
708
- }
751
+ const body = await boundedBody(request, auth.maxBodyBytes);
752
+ if (body instanceof Response)
753
+ return body;
709
754
  return strategy.handler(new Request(request.url, { method: request.method, headers: request.headers, body, signal: request.signal }));
710
755
  }
711
756
  }
@@ -735,6 +780,8 @@ export const buildAsas = (language, { kayan, global, auth, http, messages, state
735
780
  // `readOnly` writes nothing: no push.
736
781
  for (const [, store] of stores)
737
782
  await store.connect({ readOnly });
783
+ if (!readOnly)
784
+ await refuseExtraTenants();
738
785
  },
739
786
  destroy: async () => {
740
787
  for (const [, store] of stores)
@@ -10,13 +10,36 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
10
10
  // Each generator is imported when its word is asked for, so `better-auth/db` loads
11
11
  // only for `generate auth` and the `generate --check` sweep.
12
12
  import { realpathSync } from 'node:fs';
13
+ import * as modules from 'node:module';
13
14
  import { pathToFileURL } from 'node:url';
14
15
  import { Refusal } from '../../mithaq/refusal.js';
15
- const verbs = ['generate', 'lock', 'migrate', 'screens', 'upgrade'];
16
+ const verbs = ['eject', 'generate', 'lock', 'migrate', 'screens', 'upgrade'];
16
17
  const nouns = ['auth', 'messages', 'screens'];
17
- const usage = 'Usage: mithaq generate auth [--check] | mithaq generate messages [--check] [--no-data-scan] | mithaq generate screens [--check] | mithaq generate --check | mithaq lock [--check] | mithaq migrate [--check] [--name <slug>] | mithaq screens | mithaq upgrade --check | mithaq <target> <command> [--check]';
18
+ const usage = 'Usage: mithaq generate auth [--check] | mithaq generate messages [--check] [--no-data-scan] | mithaq generate screens [--check] | mithaq generate --check | mithaq lock [--check] | mithaq migrate [--check] [--name <slug>] | mithaq screens | mithaq upgrade --check | mithaq eject <item> | mithaq eject --prune | mithaq <target> <command> [--check]';
18
19
  // Run as a command, not imported. The bin is a symlink, so compare real paths.
19
20
  if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
21
+ // A project's sources may import each other with no extension, as Vite, Bun and drizzle-kit
22
+ // resolve them and Node does not, so a relative import that names no file is tried as `.ts`,
23
+ // then as a folder's `index.ts`. Only after Node's own resolution failed, never under node_modules.
24
+ modules.registerHooks?.({
25
+ resolve: (specifier, context, next) => {
26
+ try {
27
+ return next(specifier, context);
28
+ }
29
+ catch (error) {
30
+ const code = error.code;
31
+ if (!/^\.\.?\//.test(specifier) || context.parentURL?.includes('/node_modules/') || (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'ERR_UNSUPPORTED_DIR_IMPORT'))
32
+ throw error;
33
+ for (const tail of ['.ts', '/index.ts']) {
34
+ try {
35
+ return next(`${specifier}${tail}`, context);
36
+ }
37
+ catch { }
38
+ }
39
+ throw error;
40
+ }
41
+ },
42
+ });
20
43
  try {
21
44
  const [, , verb, noun, ...rest] = process.argv;
22
45
  // Before anything reads the project: outside one there is no `src/asas.ts` to import.
@@ -31,7 +54,10 @@ if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
31
54
  // and never a second word; a target named `migrate` is reached by no spelling.
32
55
  const misplaced = (verb === 'migrate' || verb === 'upgrade') && noun !== undefined && !noun.startsWith('--');
33
56
  const unchecked = verb === 'upgrade' && !flags.includes('--check');
34
- if (verb === undefined || misplaced || unchecked || (verb === 'generate' && !(sweeping || nouns.includes(noun ?? '')))) {
57
+ // `eject` takes one item or `--prune`, never both and never neither.
58
+ const ejecting = noun !== undefined && !noun.startsWith('--');
59
+ const unejected = verb === 'eject' && (ejecting ? flags.length > 0 : flags.join(' ') !== '--prune');
60
+ if (verb === undefined || misplaced || unchecked || unejected || (verb === 'generate' && !(sweeping || nouns.includes(noun ?? '')))) {
35
61
  console.error(usage);
36
62
  process.exit(1);
37
63
  }
@@ -88,6 +114,12 @@ if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
88
114
  if (code)
89
115
  process.exit(code);
90
116
  }
117
+ else if (verb === 'eject') {
118
+ const { eject, prune } = await import('./upgrade.js');
119
+ const code = ejecting ? await eject(project, noun) : await prune(project);
120
+ if (code)
121
+ process.exit(code);
122
+ }
91
123
  else if (verb === 'upgrade') {
92
124
  const code = await upgrade();
93
125
  if (code)
@@ -1,20 +1,33 @@
1
- /** The hash of a file as `shadcn add` writes it, without its marker. */
1
+ /** The hash of a copy `shadcn add` made, without its marker and the leading comments it dropped. */
2
2
  export declare const hashOf: (text: string) => string;
3
- /** Appends the marker to every file of every item `shadcn build` wrote into `built`, the registry's `r/`. */
4
- export declare const mark: (built: URL) => Promise<void>;
3
+ /** A held file's state against the package's text of it, read as `upgrade --check` reads it. */
4
+ export declare const stateOf: (text: string, source: string) => 'up to date' | 'up to date, edited' | 'differs' | 'differs, edited' | 'unknown';
5
5
  export type Copied = {
6
- /** The file, relative to the project, or the target or item name that resolved outside it. */
6
+ /** The file, relative to the project, or the target or package path that resolved outside it. */
7
7
  path: string;
8
8
  state: 'up to date' | 'up to date, edited' | 'differs' | 'differs, edited' | 'unknown' | 'outside' | 'removed';
9
- /** The commit the copy's marker names, when it carries one. */
9
+ /** The version or commit the file's marker names, when it carries one. */
10
10
  from?: string;
11
- /** The commit the installed registry was built at. */
11
+ /** The installed mithaq-admin's version. */
12
12
  to?: string;
13
+ /** The item of a file the project always holds, which it never deletes: it ejects the item again instead. */
14
+ item?: string;
13
15
  };
14
16
  /**
15
- * Every registry file the project holds where `shadcn add` writes it, with its state. Reads only;
16
- * a path that climbs out of the project or the registry is listed as outside, never followed.
17
+ * Every package file the project holds at its target, ejected or always the project's, with its state. Reads only;
18
+ * a path that climbs out of the project or the package is listed as outside, never followed.
17
19
  */
18
20
  export declare const upgradeCheck: (project: URL) => Promise<Copied[] | undefined>;
19
- /** `mithaq upgrade --check`: one line per copied file, and 1 where any differs. It never writes. */
21
+ /** `mithaq upgrade --check`: one line per file the project holds, and 1 where any differs. It never writes. */
20
22
  export declare const run: (project: URL) => Promise<number>;
23
+ /**
24
+ * `mithaq eject <item>`: writes the item's own files where the project keeps them, each byte for byte with a marker,
25
+ * and never over a file already there. It copies no other item and installs nothing: the package serves every
26
+ * sibling, so it only names a primitive or an npm package the project lacks.
27
+ */
28
+ export declare const eject: (project: URL, name: string) => Promise<number>;
29
+ /**
30
+ * `mithaq eject --prune`: deletes every marked file in the folders pieces are ejected into whose text still hashes
31
+ * to its marker, so the package draws it again. Never an edited, unmarked or `~/` file.
32
+ */
33
+ export declare const prune: (project: URL) => Promise<number>;