@drhomet/mithaq 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/README.md +160 -2
  6. package/dist/asas/actor.d.ts +96 -0
  7. package/dist/asas/actor.js +114 -0
  8. package/dist/asas/build.d.ts +239 -0
  9. package/dist/asas/build.js +834 -0
  10. package/dist/asas/cli/mithaq.d.ts +2 -0
  11. package/dist/asas/cli/mithaq.js +177 -0
  12. package/dist/asas/cli/upgrade.d.ts +33 -0
  13. package/dist/asas/cli/upgrade.js +256 -0
  14. package/dist/asas/http/fetch.d.ts +41 -0
  15. package/dist/asas/http/fetch.js +178 -0
  16. package/dist/asas/http/orpc.d.ts +45 -0
  17. package/dist/asas/http/orpc.js +153 -0
  18. package/dist/asas/runner/bulk.d.ts +20 -0
  19. package/dist/asas/runner/bulk.js +196 -0
  20. package/dist/asas/runner/errors.d.ts +63 -0
  21. package/dist/asas/runner/errors.js +75 -0
  22. package/dist/asas/runner/read.d.ts +72 -0
  23. package/dist/asas/runner/read.js +601 -0
  24. package/dist/asas/runner/runtime.d.ts +21 -0
  25. package/dist/asas/runner/runtime.js +8 -0
  26. package/dist/asas/runner/steps.d.ts +418 -0
  27. package/dist/asas/runner/steps.js +277 -0
  28. package/dist/asas/runner/store.d.ts +4 -0
  29. package/dist/asas/runner/store.js +21 -0
  30. package/dist/asas/runner/tenant.d.ts +54 -0
  31. package/dist/asas/runner/tenant.js +275 -0
  32. package/dist/asas/runner/write.d.ts +606 -0
  33. package/dist/asas/runner/write.js +1019 -0
  34. package/dist/auth/better-auth/access.d.ts +29 -0
  35. package/dist/auth/better-auth/access.js +103 -0
  36. package/dist/auth/better-auth/generate.d.ts +31 -0
  37. package/dist/auth/better-auth/generate.js +328 -0
  38. package/dist/auth/better-auth/index.d.ts +94 -0
  39. package/dist/auth/better-auth/index.js +81 -0
  40. package/dist/auth/better-auth/tenant.d.ts +26 -0
  41. package/dist/auth/better-auth/tenant.js +34 -0
  42. package/dist/auth/local/index.d.ts +285 -0
  43. package/dist/auth/local/index.js +738 -0
  44. package/dist/base/decimal.d.ts +9 -0
  45. package/dist/base/decimal.js +16 -0
  46. package/dist/base/fields.d.ts +124 -0
  47. package/dist/base/fields.js +52 -0
  48. package/dist/base/index.d.ts +47 -0
  49. package/dist/base/index.js +5 -0
  50. package/dist/base/json-schema.d.ts +7 -0
  51. package/dist/base/json-schema.js +35 -0
  52. package/dist/base/kinds.d.ts +75 -0
  53. package/dist/base/kinds.js +123 -0
  54. package/dist/base/rules.d.ts +73 -0
  55. package/dist/base/rules.js +81 -0
  56. package/dist/drizzle/index.d.ts +308 -0
  57. package/dist/drizzle/index.js +951 -0
  58. package/dist/drizzle/kinds.d.ts +28 -0
  59. package/dist/drizzle/kinds.js +43 -0
  60. package/dist/drizzle/migrate.d.ts +159 -0
  61. package/dist/drizzle/migrate.js +1328 -0
  62. package/dist/drizzle/proxy/index.d.ts +1 -0
  63. package/dist/drizzle/proxy/index.js +1 -0
  64. package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
  65. package/dist/drizzle/proxy/node-postgres.js +1 -0
  66. package/dist/drizzle/proxy/pg-core.d.ts +1 -0
  67. package/dist/drizzle/proxy/pg-core.js +1 -0
  68. package/dist/drizzle/rules.d.ts +336 -0
  69. package/dist/drizzle/rules.js +44 -0
  70. package/dist/index.d.ts +35 -0
  71. package/dist/index.js +26 -0
  72. package/dist/mithaq/define.d.ts +335 -0
  73. package/dist/mithaq/define.js +57 -0
  74. package/dist/mithaq/generate-messages.d.ts +38 -0
  75. package/dist/mithaq/generate-messages.js +235 -0
  76. package/dist/mithaq/language.d.ts +374 -0
  77. package/dist/mithaq/language.js +76 -0
  78. package/dist/mithaq/lock.d.ts +71 -0
  79. package/dist/mithaq/lock.js +329 -0
  80. package/dist/mithaq/message-file.d.ts +13 -0
  81. package/dist/mithaq/message-file.js +29 -0
  82. package/dist/mithaq/messages.d.ts +55 -0
  83. package/dist/mithaq/messages.js +111 -0
  84. package/dist/mithaq/refusal.d.ts +6 -0
  85. package/dist/mithaq/refusal.js +6 -0
  86. package/dist/mithaq/resolve.d.ts +10 -0
  87. package/dist/mithaq/resolve.js +1014 -0
  88. package/dist/mithaq/rows.d.ts +87 -0
  89. package/dist/mithaq/rows.js +174 -0
  90. package/dist/mithaq/rukn.d.ts +72 -0
  91. package/dist/mithaq/rukn.js +15 -0
  92. package/dist/mithaq/standard-schema.d.ts +38 -0
  93. package/dist/mithaq/standard-schema.js +2 -0
  94. package/dist/mithaq/types.d.ts +386 -0
  95. package/dist/mithaq/types.js +15 -0
  96. package/dist/mithaq/user.d.ts +19 -0
  97. package/dist/mithaq/user.js +16 -0
  98. package/dist/mithaq/where.d.ts +32 -0
  99. package/dist/mithaq/where.js +87 -0
  100. package/dist/rbac/index.d.ts +105 -0
  101. package/dist/rbac/index.js +182 -0
  102. package/dist/rbac/steps.d.ts +22 -0
  103. package/dist/rbac/steps.js +161 -0
  104. package/dist/rich-text/index.d.ts +28 -0
  105. package/dist/rich-text/index.js +59 -0
  106. package/dist/rich-text/rich-text.d.ts +106 -0
  107. package/dist/rich-text/rich-text.js +249 -0
  108. package/dist/rich-text/value.d.ts +55 -0
  109. package/dist/rich-text/value.js +126 -0
  110. package/dist/screen/client.d.ts +156 -0
  111. package/dist/screen/client.js +71 -0
  112. package/dist/screen/generate.d.ts +14 -0
  113. package/dist/screen/generate.js +268 -0
  114. package/dist/screen/index.d.ts +496 -0
  115. package/dist/screen/index.js +663 -0
  116. package/dist/screen/react.d.ts +97 -0
  117. package/dist/screen/react.js +58 -0
  118. package/dist/screen/studio.d.ts +24 -0
  119. package/dist/screen/studio.js +68 -0
  120. package/dist/service/index.d.ts +63 -0
  121. package/dist/service/index.js +15 -0
  122. package/dist/storage/allowed-type.d.ts +5 -0
  123. package/dist/storage/allowed-type.js +7 -0
  124. package/dist/storage/index.d.ts +42 -0
  125. package/dist/storage/index.js +43 -0
  126. package/dist/storage/storage.d.ts +386 -0
  127. package/dist/storage/storage.js +412 -0
  128. package/dist/targets.d.ts +14 -0
  129. package/dist/targets.js +16 -0
  130. package/dist/versions/index.d.ts +37 -0
  131. package/dist/versions/index.js +8 -0
  132. package/dist/versions/versions.d.ts +104 -0
  133. package/dist/versions/versions.js +160 -0
  134. package/dist/vite.d.ts +7 -0
  135. package/dist/vite.js +81 -0
  136. package/dist/zod/index.d.ts +193 -0
  137. package/dist/zod/index.js +122 -0
  138. package/dist/zod/kinds.d.ts +36 -0
  139. package/dist/zod/kinds.js +58 -0
  140. package/dist/zod/rules.d.ts +117 -0
  141. package/dist/zod/rules.js +67 -0
  142. package/docs/packages/mithaq/README.md +962 -0
  143. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  144. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  145. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  146. package/docs/packages/mithaq-admin/README.md +312 -0
  147. package/docs/templates/asas/README.md +1056 -0
  148. package/package.json +92 -4
  149. package/skills/add-field/SKILL.md +27 -0
  150. package/skills/add-kayan/SKILL.md +26 -0
  151. package/skills/eject-admin-piece/SKILL.md +28 -0
  152. package/skills/upgrade-mithaq/SKILL.md +35 -0
@@ -0,0 +1,962 @@
1
+ # The mithaq language
2
+
3
+ This page is the mental model of mithaq, in the order you need it. Each section
4
+ gives a short definition, a small example taken from the template, and a pointer
5
+ to the code. Paths are relative to the repository root; a pointer names a file
6
+ and, in backticks, the symbol or a short fragment to search for.
7
+
8
+ Three Arabic words name the main parts:
9
+
10
+ - **kayan** (plural the same): an entity or collection, such as `courses`.
11
+ - **rukn** (plural **arkan**): a pillar, a pluggable part that brings kinds,
12
+ rules or targets.
13
+ - **asas**: the foundation, the runtime that `buildAsas` builds from your kayan.
14
+
15
+ If you have not made a project yet, start with
16
+ [Getting started](../../templates/asas/README.md#getting-started). Tenancy has its own page,
17
+ [Tenancy](src/asas/runner/README.md).
18
+
19
+ ## The language
20
+
21
+ The language is the one object a project hands to `defineMithaq` in
22
+ `src/mithaq.ts`. It names the locales, the arkan and the targets. Everything
23
+ else in the project is written in its words.
24
+
25
+ ```ts
26
+ const language = defineMithaq({
27
+ locales: ['ar', 'en'] as const,
28
+ arkan: [base, richText({ nodes: richTextNodes }), files, versions({ max: 100 })],
29
+ targets: {
30
+ drizzle: drizzleStore,
31
+ zod: zodTarget({ kinds: zodKinds, rules: zodRules, idKinds: zodIdKinds }),
32
+ rbac: accessTarget(),
33
+ tenant: betterAuthTenant({ relationTo: 'organization', onDelete: 'restrict', newRows: 'active', organizations: 'many' }),
34
+ service: serviceTarget({ /* ... */ }),
35
+ dashboard: dashboardScreen,
36
+ },
37
+ // Each path is from the folder every mithaq command runs in, the asas's.
38
+ generate: { language: '@asas/mithaq', features: '../features/', auth: '../features/auth/', providers: '@asas/mithaq/auth', messages: 'src/messages/', migrations: 'src/migrations/', screens: { /* ... */ } },
39
+ })
40
+
41
+ export const { defineKayan, defineGlobal, buildAsas, rows, user, targets: { tenant } } = language
42
+ ```
43
+
44
+ Where it lives: `packages/mithaq/src/mithaq/language.ts` `defineMithaq`; the
45
+ template's language is `templates/mithaq/src/mithaq.ts`.
46
+
47
+ ### Root keys
48
+
49
+ - `locales`: every locale the project's copy is written in. Each label then
50
+ needs one string per locale, so a half-translated project fails to compile.
51
+ mithaq never picks a locale itself.
52
+ - `arkan`: the pillars the kinds, rules and some targets come from. Kinds and
53
+ rules come from arkan alone: a `kinds` or `rules` key at the root is refused.
54
+ - `targets`: one entry per target, by the name the project gives it. A
55
+ target's name is how a kayan addresses it (`targets.dashboard`,
56
+ `targets.versions`).
57
+ - Any other key, such as `generate`, is the project's own. mithaq passes it
58
+ through typed, and the generators read `generate` to find where to write.
59
+
60
+ The service target's own keys (`beforeSteps`, `authorize`, `softDelete`,
61
+ `store`, `schema` and the rest) are refused at the root, by name, and so is
62
+ `operations`: each kayan declares its own.
63
+
64
+ A caller's `where` lists at most 1000 values in one `in` or `not_in`, and
65
+ holds at most 200 fields, `and`s and `or`s however nested; past either it is
66
+ refused 400 before the store is asked. An access callback's `where` is the
67
+ project's own and is not held to them.
68
+
69
+ ### What it returns
70
+
71
+ `defineMithaq` returns the language's own keys (`locales`, `kinds`, `rules`,
72
+ `targets` and the rest) and five functions bound to the language:
73
+
74
+ - `defineKayan`, which declares a kayan in this language's kinds and rules;
75
+ - `defineGlobal`, which declares a kayan of one row;
76
+ - `buildAsas`, which builds the runtime;
77
+ - `rows`, which compiles an access row table, with its role keys checked
78
+ against the access target's roles;
79
+ - `user`, which writes a reference to a key of the actor, such as `user.id`,
80
+ checked against the actor the access target reads.
81
+
82
+ A kayan is checked against the language it was declared in: a kind the arkan
83
+ do not bring, a rule its kind does not accept, or a label missing a locale is a
84
+ type error.
85
+
86
+ ### The frozen vocabulary
87
+
88
+ The vocabulary of `defineMithaq` is its locales, the kinds (each one's label,
89
+ message and accepted rules), the rules (each one's message and excludes), the
90
+ targets (each one's name and capability) and the list of root keys. It changes
91
+ only with the project owner's signed decision.
92
+
93
+ `mithaq.lock` records that vocabulary as plain data, with a signature. `mithaq
94
+ lock` writes and signs it; it signs only when a person types back a code shown
95
+ in a popup, never in the command's own output. `mithaq lock --check`, and
96
+ `mithaq generate --check` as its first step, fail when the vocabulary no longer
97
+ matches the lock or the seal does not verify.
98
+
99
+ The lock is optional. A project without `mithaq.lock` passes `mithaq lock
100
+ --check` with a notice, and `mithaq lock` locks it.
101
+
102
+ Everything outside that vocabulary is free to change: every `defineKayan`,
103
+ `defineGlobal` and `buildAsas` call, and the rest of `defineMithaq` (screen
104
+ pages, a screen's kayan list, shells, components, roles, steps and `generate`
105
+ paths).
106
+
107
+ Where it lives: `packages/mithaq/src/mithaq/lock.ts` `vocabularyOf` and
108
+ `checkLock`. The commands are in [The `mithaq` command](src/asas/cli/README.md).
109
+
110
+ ## Arkan
111
+
112
+ A rukn is one pillar of capability: some kinds, some rules, some targets, and
113
+ the capabilities it needs from other targets. Two arkan may not bring the same
114
+ kind, rule or target, and a rukn's target may not share a name with one the
115
+ project lays down; `buildAsas` names both sides of the clash.
116
+
117
+ ```ts
118
+ export const slugs = defineRukn({
119
+ name: 'slugs',
120
+ rules: {
121
+ slug: rule<boolean>({
122
+ accepts: (value): value is boolean => typeof value === 'boolean',
123
+ message: { en: 'This is not a slug.', ar: 'هذا ليس معرفا نصيا.' },
124
+ }),
125
+ },
126
+ })
127
+ ```
128
+
129
+ Where it lives: `packages/mithaq/src/mithaq/rukn.ts` `defineRukn` and
130
+ `composeArkan`. A rukn of your own, and the target maps it needs, are covered in
131
+ [Add a rukn](#add-a-rukn).
132
+
133
+ ### The shipped arkan
134
+
135
+ - **`base`**: the shipped kinds and rules, listed below. Every project lays it
136
+ down first. `packages/mithaq/src/base/index.ts` `base`.
137
+ - **`richText({ nodes })`**: the `richText` kind, a Lexical tree checked
138
+ against the project's own node list. It can be searched and is never sorted.
139
+ It is imported from `mithaq/rich-text`, the one entry that loads Lexical, so a
140
+ project that uses it installs `lexical`, `@lexical/extension`, `@lexical/link`,
141
+ `@lexical/list` and `@lexical/rich-text` (mithaq's optional peers) and a project
142
+ without it installs none. `packages/mithaq/src/rich-text/index.ts` `richText`.
143
+ - **`storage(config)`**: the `upload` kind, a relationship drawn as a file
144
+ picker, and the `storage` target over the project's config. The config holds
145
+ the signing secret, so the module that builds the language stays on the
146
+ server. `packages/mithaq/src/storage/index.ts` `storage`.
147
+ - **`versions({ max })`**: the `versions` target. A kayan that writes
148
+ `targets.versions` keeps up to `max` past copies of each row, or its own
149
+ window, and may keep drafts. It needs a target of capability `store`.
150
+ `packages/mithaq/src/versions/index.ts` `versions`.
151
+
152
+ The fields a kayan spreads to use them ship beside each one: `uploadFields` for
153
+ a kayan that holds files, `draftFields` (the `_status` field) and
154
+ `versionOperations` for a kayan that keeps drafts and versions.
155
+
156
+ ## Kinds and rules
157
+
158
+ A kind is what a field holds: its value's type, the label and message a person
159
+ reads, and the rules it accepts. A rule is a constraint or a behaviour a field
160
+ switches on in `rules`.
161
+
162
+ <!-- from: templates/features/courses/courses/courses.ts -->
163
+ ```ts
164
+ title: {
165
+ kind: 'text',
166
+ label: { en: 'Title', ar: 'العنوان' },
167
+ rules: { required: true, trim: true, maxLength: 120, index: true },
168
+ },
169
+ ```
170
+
171
+ ### Kinds
172
+
173
+ From `base` (`packages/mithaq/src/base/kinds.ts` `defaultKinds`):
174
+
175
+ - `text`: a string.
176
+ - `email`: a string written as an email address.
177
+ - `number`: a JavaScript number.
178
+ - `integer`: a whole number in Postgres `integer` range.
179
+ - `decimal`: a decimal number carried as text, so money never rounds.
180
+ - `checkbox`: `true` or `false`.
181
+ - `datetime`: a moment, as a `Date`.
182
+ - `date`: a calendar day, as an ISO string such as `2026-02-28`.
183
+ - `textArray`: a list of strings.
184
+ - `uuid`: a uuid string; also the kind of the shipped `id`.
185
+ - `relationship`: the id of one row of another kayan, or a set of ids with
186
+ `hasMany: true`. Without `onDelete` it restricts: a row a live row or a row's
187
+ latest draft still names is not deleted, and the refusal counts those rows and
188
+ names by title the ones the caller finds and whose title it reads. A title
189
+ that is itself a relationship is never sent, and a `date` or `datetime` title
190
+ is marked `titleKind` for the screen to format. A person a restricting
191
+ relationship names is not removed by Better Auth's delete routes either: the
192
+ build hands the provider `referenced(id)`, which counts those rows first.
193
+ `filterOptions: ({ user, tx }) => Where | boolean`
194
+ narrows what its picker offers, as Payload's does: the picker reads `for`
195
+ the field and the server ANDs the answer beside the target's own read, so it
196
+ never widens it. A create or an update is held to the same answer, refused
197
+ in the words a missing row gets, and is handed its transaction as `tx`.
198
+ - `join`: the rows of another kayan whose `on` relationship points back at
199
+ this row. It builds no column; a read populates it through the child kayan's
200
+ own access.
201
+ - `json`: any JSON value, Payload's json field. It is a `jsonb` column holding
202
+ the value itself, so a row Payload wrote reads back unchanged and Payload
203
+ reads what mithaq writes; Postgres keeps no key order and one copy of a
204
+ repeated key. Its rules are `required`, `defaultValue` and `providedBy`.
205
+ Every json field names `maxBytes`, the most UTF-8 bytes its value's JSON
206
+ text may hold: `defaultJsonMaxBytes` (1,000,000 bytes) or a number of its
207
+ own.
208
+ `jsonSchema` checks each written value against a JSON Schema, as Payload's
209
+ does:
210
+
211
+ ```ts
212
+ meta: {
213
+ kind: 'json',
214
+ label: { en: 'Metadata', ar: 'البيانات الوصفية' },
215
+ maxBytes: defaultJsonMaxBytes,
216
+ jsonSchema: {
217
+ uri: 'a://b/meta.json',
218
+ fileMatch: ['a://b/meta.json'],
219
+ schema: { type: 'object', properties: { size: { type: 'integer', minimum: 0 } }, required: ['size'] },
220
+ },
221
+ },
222
+ ```
223
+
224
+ A value JSON cannot say (`undefined`, `NaN`, a `Date`, a function, a cycle),
225
+ one nested past 64 levels, and a string or key holding a NUL or half a
226
+ surrogate pair, which Postgres cannot store, are refused 400. The schema is
227
+ held in the field and never fetched from its `uri`. It is checked with Ajv,
228
+ as draft 2020-12, or as draft-07 where `$schema` names it, with or without
229
+ its trailing `#`. A keyword, a format or a reference Ajv cannot check is
230
+ refused at build rather than skipped, and so is a `defaultValue` the
231
+ field's own schema or `maxBytes` refuses. Rows stored before a schema was
232
+ added still read; their next save is held to it.
233
+
234
+ A schema's `pattern` runs as JavaScript's own `RegExp`, on the server, over
235
+ each value a writer sends. A pattern that backtracks without end on some
236
+ input, such as `^(a+)+$`, holds the server for as long as it runs, so write
237
+ patterns that cannot.
238
+
239
+ A query names a path into the value with dots, `{ 'meta.size': { equals: 3 } }`,
240
+ each segment a key, and a list met at any segment, a list inside a list
241
+ included, is matched by any of its entries, as Payload's `$.a[*].b[*]` is.
242
+ It takes `equals`, `not_equals`, `in`, `not_in`, `exists`, `like` and
243
+ `contains`, each compared with a string, a number, a boolean or `null`. The
244
+ field's read access covers every path into it, and a path that reaches a
245
+ json field across a relationship is refused. Nothing orders by one. The
246
+ admin draws it as a left-to-right JSON text box, and a row's cell as one
247
+ line of it.
248
+
249
+ Where mithaq departs from Payload:
250
+
251
+ - A JSON `null` is no value, as a missing key is: `exists: false` and
252
+ `equals: null` match both, where Payload's `exists` finds a `null`.
253
+ - `not_equals` and `not_in` hold where the path holds a value and no entry
254
+ of it equals one named, so a list holding the value does not match.
255
+ Payload's holds where any entry differs.
256
+ - An empty `{}` or `[]` is checked against the schema; Payload skips it.
257
+ - A draft-04 schema is refused at build; Payload reads one.
258
+ - `contains` is a substring and `like` every word of it, both ignoring
259
+ case, over each entry as text: a string without its quotes, and an object
260
+ or a list as its JSON. Payload's `like` is a regular expression.
261
+
262
+ From the other arkan:
263
+
264
+ - `richText` (`richText` rukn): a rich text tree.
265
+ - `upload` (`storage` rukn): one row of a kayan that holds files. A file a row
266
+ still names is never deleted from under it.
267
+
268
+ ### Rules
269
+
270
+ From `base` (`packages/mithaq/src/base/rules.ts` `defaultRules`):
271
+
272
+ - `required`: the value must be there.
273
+ - `unique`: no two rows hold the same value. On a per-organization kayan that
274
+ means within the organization; see [Tenancy](src/asas/runner/README.md).
275
+ - `index`: the column is indexed.
276
+ - `defaultValue`: a literal, JSON for a `json` field, or a reference such as
277
+ `user.id` to the actor's value. Excludes `defaultNow`.
278
+ - `defaultNow`: the moment of the write, on create.
279
+ - `nowOnUpdate`: the moment of the write, on every update.
280
+ - `providedBy`: who supplies the value on `create` and on `update`: `client`,
281
+ `application` or `never`. A field that declares nothing is the client's.
282
+ - `localized`: one value per locale, stored as one column per locale.
283
+ - `maxLength`, `minLength`: the length of a string.
284
+ - `pattern`: a `RegExp` the string must match.
285
+ - `min`, `max`: bounds on a number, or on an ISO day for `date`.
286
+ - `precision`, `scale`: a decimal's size, declared together.
287
+ - `minRows`, `maxRows`: how few and how many entries a list holds.
288
+ - `uppercase`, `lowercase`, `trim`: rewrite the value before it is checked.
289
+ - `onDelete`: what happens to this row when the row it points at is deleted:
290
+ `restrict`, the default of `relationship` and `upload`, `cascade` or `set null`.
291
+ A set takes `restrict` or `cascade`, since its join row holds no null.
292
+
293
+ Each kind lists the rules it accepts; a rule a kind does not accept is a type
294
+ error on the field.
295
+
296
+ ## Kayan and globals
297
+
298
+ ### `defineKayan`
299
+
300
+ A kayan is declared once, as data, and every target derives its part from that
301
+ declaration: the table, the validation shapes, the operations and the screens.
302
+
303
+ <!-- from: templates/features/tags/tags/tags.ts -->
304
+ ```ts
305
+ export const tags = defineKayan({
306
+ name: 'tags',
307
+ messages: {
308
+ singular: { en: 'Tag', ar: 'وسم' },
309
+ plural: { en: 'Tags', ar: 'الوسوم' },
310
+ },
311
+ targets: {
312
+ // ...
313
+ dashboard: {
314
+ useAsTitle: 'name',
315
+ // ...
316
+ },
317
+ service: {
318
+ operations: ({ defaults }) => ({
319
+ ...defaults,
320
+ // ...
321
+ }),
322
+ },
323
+ },
324
+ fields: {
325
+ ...defaultFields,
326
+ organization,
327
+ name: {
328
+ kind: 'text',
329
+ label: { en: 'Name', ar: 'الاسم' },
330
+ rules: { localized: true, required: true, trim: true, maxLength: 60, index: true },
331
+ },
332
+ },
333
+ })
334
+ ```
335
+
336
+ Where it lives: `packages/mithaq/src/mithaq/define.ts` `defineKayan`; the
337
+ example is `templates/features/tags/tags/tags.ts`.
338
+
339
+ **Fields.** The key order of `fields` is the column order. Each field names a
340
+ `kind`, a `label` in every locale, and its `rules`. It may also carry a
341
+ `description`, a `placeholder`, a `prefix` and `suffix`, `options` (the closed
342
+ list a text field's value is one of), `access` for the field, a `dbName`, and
343
+ `targets.<name>` for what one target says about it. A store's or schema
344
+ target's entry under `targets` replaces what that target would derive; a
345
+ screen's entry says how the field is drawn.
346
+
347
+ **Default fields.** A project spreads its id and timestamps; nothing is merged
348
+ in for it.
349
+
350
+ - `defaultIdField`: `id`, a uuid Postgres fills when a create leaves it out.
351
+ - `defaultTimestampFields`: `updatedAt`, written by the runner on every write
352
+ that changes a row, and `createdAt`, written on insert and indexed.
353
+ - `defaultFields`: both of the above, `{ ...defaultIdField,
354
+ ...defaultTimestampFields }`.
355
+
356
+ Every kayan needs an `id`. The template's `expectedUpdatedAt` step, which
357
+ refuses a stale update with a 409, refuses to run on a kayan without
358
+ `updatedAt`. The default fields are not vocabulary: `mithaq.lock` does not
359
+ record them, so changing them needs no signature. Where it lives:
360
+ `packages/mithaq/src/base/fields.ts`.
361
+
362
+ **Labels and messages.** Every label holds one string per declared locale. A
363
+ kayan's `messages` is its own copy, nested by the parts of each key:
364
+ `singular` and `plural` name the kayan on every screen, and a field's own
365
+ refusal messages go under its name (`fee: { min: { en, ar } }`). `mithaq
366
+ generate messages` writes the copy files and refuses a gap.
367
+
368
+ **Access.** `access` holds `create`, `read`, `update` and `delete`. Each is a
369
+ callback that answers `true`, `false` or a `Where`, or the project compiles them
370
+ from a row table with `rows(...)`. A field's own `access` holds `create`,
371
+ `read` and `update`, each answering `true` or `false`. See
372
+ [Access and roles](#access-and-roles) below.
373
+
374
+ **Operations.** A kayan answers exactly the operations it lists under
375
+ `targets.service.operations`, and nothing else. Spread `defaultOperations`
376
+ (`create`, `update`, `delete`, `find`, `findByID`, `count`, `updateMany`,
377
+ `deleteMany`) and leave out any you do not want, or add your own:
378
+
379
+ ```ts
380
+ operations: {
381
+ ...defaultOperations,
382
+ archive: {
383
+ input: () => archiveInput,
384
+ steps: ({ steps }) => [markArchived, steps.update],
385
+ },
386
+ },
387
+ ```
388
+
389
+ An operation is a list of steps with exactly one store or read step in it
390
+ (`steps.insert`, `steps.update`, `steps.remove`, `steps.find`,
391
+ `steps.findOne`, `steps.count`, and the bulk and version steps). Its name is
392
+ its permission key: `courses.archive`. It may also declare `input` (the shape it
393
+ parses, from the derived shapes), `output`, and three more lists:
394
+ `beforeTransaction`, `afterSteps` and `afterCommit`. Where it lives:
395
+ `packages/mithaq/src/asas/runner/steps.ts` `defaultOperations` and
396
+ `OperationDeclaration`.
397
+
398
+ **Steps.** A step is a function `(ctx, tools)` that returns the context.
399
+ `ctx` holds the kayan, `data`, the `actor` (also as `user`), `dataBefore` on an
400
+ update or delete, the transaction `tx` and the call's `context`. `tools` holds
401
+ the operation's name, its schema, the bound store writes on a write, and
402
+ `tools.of(kayan)` for a managed call to another kayan in the same transaction.
403
+
404
+ ```ts
405
+ const markArchived = (ctx: Ctx<z.infer<typeof archiveInput>>): Ctx => {
406
+ const { reason } = ctx.data
407
+ return { ...ctx, data: { ...ctx.data, archived: true, archiveReason: reason } }
408
+ }
409
+ ```
410
+
411
+ The example is `templates/features/courses/courses/courses.ts` `markArchived`.
412
+
413
+ **Indexes.** `indexes` lists the indexes over a kayan's own columns. Every
414
+ field with `unique: true` gets a default unique index. A list is built beside
415
+ those defaults, and a function `({ defaults }) => [...]` answers the whole list:
416
+
417
+ ```ts
418
+ indexes: ({ defaults }) => [
419
+ ...defaults.filter((index) => !index.fields.includes('code')),
420
+ { fields: ['code'], unique: true },
421
+ ],
422
+ ```
423
+
424
+ An entry is `{ fields, unique?, nullsNotDistinct? }`. On a per-organization
425
+ kayan a group `{ within: 'organization', unique: [...], index: [...] }` builds
426
+ indexes led by the organization field. A `unique` field that no unique index
427
+ covers is refused. The group, and what `unique` means per organization, are
428
+ explained in [Tenancy](src/asas/runner/README.md#unique-values-and-indexes). Where it
429
+ lives: `packages/mithaq/src/mithaq/resolve.ts` `uniqueRuleIndexes`.
430
+
431
+ **Versions and drafts.** A kayan keeps versions by writing `targets.versions`
432
+ and spreading `versionOperations` (`findVersions`, `findVersionByID`,
433
+ `restoreVersion`). With `drafts`, it also spreads `draftFields`: a write with
434
+ `{ draft: true }` saves a version alone, a plain read leaves a never-published
435
+ row out, and `{ draft: true }` on a read answers each row's latest version.
436
+
437
+ ```ts
438
+ targets: {
439
+ versions: { drafts: { autosave: true } },
440
+ service: { operations: { ...defaultOperations, ...versionOperations } },
441
+ },
442
+ fields: { ...defaultFields, ...draftFields, /* ... */ },
443
+ ```
444
+
445
+ Where it lives: `packages/mithaq/src/versions/versions.ts` `versionsOf`.
446
+
447
+ **Storage.** A kayan holds files by writing `targets.storage` (`mimeTypes`,
448
+ optional `fileSize`, optional `public: true`) and spreading `uploadFields`.
449
+ Exactly one of its operations makes rows, and that operation runs the config's
450
+ `uploaded` step in `beforeTransaction`. The browser asks `POST
451
+ /api/<kayan>/uploadInstructions`, uploads the file, and sends the signed
452
+ reference back in the create; `GET /api/<kayan>/file/<id>` serves it through
453
+ the kayan's own `findByID`. A global holds no files, and a kayan that holds
454
+ files keeps no versions. Where it lives:
455
+ `packages/mithaq/src/storage/storage.ts` `resolveStorage`.
456
+
457
+ **Screens.** A kayan says what each screen draws of it under
458
+ `targets.<screen>`: its title field, its pages, and per-field drawing hints. See
459
+ [Screens](#screens) below.
460
+
461
+ ### `defineGlobal`
462
+
463
+ A global is a kayan of one row, such as site settings. It spreads
464
+ `defaultGlobalFields` (the `globalType` constant) and runs
465
+ `defaultGlobalOperations`: `findOne` and `update`. The first `update` makes the
466
+ row; a read before it answers `null`. Its access takes `read` and `update`
467
+ alone.
468
+
469
+ ```ts
470
+ export const settings = defineGlobal({
471
+ name: 'settings',
472
+ fields: {
473
+ ...defaultFields,
474
+ contactEmail: { kind: 'email', label: { en: 'Contact email', ar: 'بريد التواصل' } },
475
+ ...defaultGlobalFields,
476
+ },
477
+ targets: { service: { operations: defaultGlobalOperations } },
478
+ })
479
+ ```
480
+
481
+ A global that places the organization field is one row per organization; the
482
+ template's `templates/features/settings/settings/settings.ts` is one, and
483
+ [Tenancy](src/asas/runner/README.md#globals) shows what changes. Where it lives:
484
+ `packages/mithaq/src/mithaq/define.ts` `defineGlobal`.
485
+
486
+ ## Targets
487
+
488
+ A target is one consumer of the declarations: a database, a validator, a screen,
489
+ the runner. Each declares one **capability**, and `buildAsas` treats it by that
490
+ capability alone:
491
+
492
+ | Capability | What it does | How many |
493
+ | --- | --- | --- |
494
+ | `store` | holds the rows and builds a column per field | any |
495
+ | `schema` | builds a validation shape per field and derives a kayan's shapes | any |
496
+ | `access` | names the system roles and answers what an actor holds | at most one |
497
+ | `service` | the runner: its step lists, limits and access evaluator | exactly one, named `service` |
498
+ | `screen` | draws kayan in an admin app | any, none named `screen` |
499
+ | `tenant` | says which kayan a row's organization is | at most one, named `tenant` |
500
+ | `storage` | where a kayan's files are kept | at most one |
501
+ | `versions` | where a kayan's past copies are kept | at most one |
502
+
503
+ A store or schema target has one entry per kind and per rule; a kind may also
504
+ carry its own implementation for a target's `type`. A target may carry
505
+ commands, run as `mithaq <target> <command>`.
506
+
507
+ ### Targets and adapters
508
+
509
+ A **target** is one setting in `defineMithaq`'s `targets`, each with one
510
+ capability from the table above, as many as that row allows.
511
+ It says what a kayan declaration becomes or runs through: a drizzle table,
512
+ zod shapes, service operations, screens or the tenant rule. Each kayan can
513
+ carry its own settings for a target under its own `targets`.
514
+ An **adapter** connects mithaq to a system outside it: `drizzleAdapter` is a
515
+ store target that connects to Postgres, and the storage target takes adapters
516
+ such as `r2` or `s3`. Mithaq's own service, roles, versions, screens and tenant
517
+ parts connect to nothing outside, so they are targets and not adapters.
518
+ An adapter provides a fixed set of words every adapter of its kind must
519
+ have, such as the [store words](../../templates/asas/README.md#add-a-step);
520
+ those words are not settings and are never written out in the config.
521
+ Each named default mithaq ships (`drizzleKinds`, `drizzleIdKinds`, `zodKinds`,
522
+ `defaultLimit`, `newRows: 'active'`) is written out by name in a project's config,
523
+ so reading it shows which defaults are in force.
524
+
525
+ ### The shipped targets
526
+
527
+ - **drizzle** (`store`): Postgres through Drizzle.
528
+ `drizzleAdapter({ connectionString, kinds: drizzleKinds, rules: drizzleRules, idKinds: drizzleIdKinds, prodMigrations })`. Its
529
+ `migrate` command is `mithaq migrate`. `packages/mithaq/src/drizzle/index.ts`
530
+ `drizzleAdapter`.
531
+ - **zod** (`schema`): the create, update, id, byID, row and list shapes, and
532
+ the draft shapes. `packages/mithaq/src/zod/index.ts` `zodTarget`.
533
+ - **rbac** (`access`): system roles in code, custom roles and assignments in
534
+ two kayan, and the `assign` command. `packages/mithaq/src/rbac/index.ts`
535
+ `rbacTarget`. `betterAuthTarget` is the alternative that keeps custom roles
536
+ in the provider's own rows, and `memberRoleTarget` the one with no custom
537
+ role at all: a member holds the system roles its Better Auth `member.role`
538
+ names in that organization.
539
+ - **service** (`service`): the runner. `packages/mithaq/src/service/index.ts`
540
+ `serviceTarget`.
541
+ - **screen** (`screen`): one admin app's pages. `packages/mithaq/src/screen/index.ts`
542
+ `screenTarget`.
543
+ - **tenant** (`tenant`): per-organization rows over Better Auth's
544
+ organizations. `packages/mithaq/src/auth/better-auth/tenant.ts`
545
+ `betterAuthTenant`.
546
+ - **storage** (`storage`): built by the `storage` rukn, with the `sweep`
547
+ command that finds objects no row names.
548
+ - **versions** (`versions`): built by the `versions` rukn.
549
+
550
+ ## Asas
551
+
552
+ `buildAsas` takes the kayan, the globals, the HTTP plugs and the auth settings,
553
+ checks everything, and returns the runtime.
554
+
555
+ ```ts
556
+ export const asas = buildAsas({
557
+ kayan: [...betterAuth.core, ...betterAuth.organization, roles, memberRoles, tags, courses],
558
+ global: [settings],
559
+ http: [orpcHttp({ /* ... */ }), fetchHttp({ context: requestContext, maxBodyBytes: defaultMaxBodyBytes, onError: console.error })],
560
+ statements: { ...providerStatements },
561
+ auth: { admin: { kayan: user, access: superAdmin }, onError: (error) => console.error(error), maxBodyBytes: defaultMaxBodyBytes },
562
+ })
563
+ ```
564
+
565
+ Where it lives: `packages/mithaq/src/asas/build.ts` `buildAsas`; the template's
566
+ is `templates/asas/src/asas.ts`.
567
+
568
+ ### Defaults are explicit
569
+
570
+ Core never falls back to a behaviour of its own. Each default is a named
571
+ `default<Name>` that the project spreads or names, so the project sees and owns
572
+ every behaviour it might change. `buildAsas` refuses a config that leaves a
573
+ required one out:
574
+
575
+ - `http`: at least one plug, such as `fetchHttp({ maxBodyBytes:
576
+ defaultMaxBodyBytes, onError: console.error })`. Each plug requires its
577
+ `maxBodyBytes`, the largest request body it reads, and answers one past it
578
+ with 413 before it reads the rest; the template names `defaultMaxBodyBytes`
579
+ (4 MiB) on both. Files are never sent this way. Each plug requires its
580
+ `onError` too, handed every fault it answers 500.
581
+ - `auth.onError` and `auth.maxBodyBytes`, the largest body an auth route
582
+ reads, once any kayan carries auth.
583
+ - On the service target: `authorize` (`defaultAuthorize`), `depth`
584
+ (`defaultDepth`, 8), `defaultLimit` (10), `maxLimit` (100), `joinLimit` (10),
585
+ `bulkLimit` (1000), `store` and `schema`. An `audit` step needs an `audit`
586
+ sink.
587
+ - On the storage config: `secret`, `adapters`, `adapter`, `fileSize`
588
+ (`defaultFileSize`), `maxBodyBytes` (`defaultMaxBodyBytes`), which bounds
589
+ the instructions request as a plug's bounds a call, `onError`, and the functions `key`, `verify`,
590
+ `disposition`, `sweep`, `instructions`, `uploaded`, `removed` and `serve`.
591
+
592
+ A default that holds policy or talks to the outside world, such as the storage
593
+ functions or the `expectedUpdatedAt` step, is a registry file copied into the
594
+ project, which the project edits there.
595
+
596
+ ### What it returns
597
+
598
+ - `handler(request)`: answers every HTTP request. It serves
599
+ `/api/auth/<strategy>/*`, `GET /api/access` (what the actor may do), the file
600
+ routes, and then asks each plug in order, answering 404 when none matched.
601
+ - `operations`: the flat table of every built operation, one entry per
602
+ operation of every kayan and global, which the plugs read.
603
+ - `kayan.<name>.operations` and `global.<name>.operations`: each operation as a
604
+ function, `(input, { actor })`, for code in the app:
605
+ `asas.kayan.courses.operations.find({ limit: 20 }, { actor })`.
606
+ - `connect({ readOnly? })`: reaches every store and prepares it, and with
607
+ `readOnly` writes nothing. With the drizzle store's `prodMigrations`, it runs
608
+ or refuses pending migrations; see [Migrations in production](src/drizzle/README.md).
609
+ - `destroy()`: closes every store.
610
+ - `catalog` and `statements`: every permission key, as a list and in Better
611
+ Auth's shape.
612
+ - `targets`, `auth` (the strategies and `auth.actor(headers)`), `router` and
613
+ `messages`.
614
+
615
+ ## Operations and the runner
616
+
617
+ Every operation runs through the runner in a fixed order. A step list cannot
618
+ skip a check, because the checks belong to the runner, not to the list.
619
+
620
+ ### Create, update and delete
621
+
622
+ 1. The depth cap and the call's context. A write nested past `depth` throws.
623
+ 2. On an update or delete, the id is checked against the id shape: a 400, not a
624
+ store error.
625
+ 3. On a per-organization kayan, the organization scope is checked; see
626
+ [Tenancy](src/asas/runner/README.md).
627
+ 4. Access: the kayan's access answer and the permission key
628
+ `<kayan>.<operation>`. A create needs `true`; an update or delete may get a
629
+ `Where` that narrows the row it reaches.
630
+ 5. `beforeTransaction`, outside the transaction. No row is loaded yet.
631
+ 6. The transaction opens. An update or delete loads its row under the access
632
+ answer and locks it as Postgres's own statement would: an update `FOR NO KEY
633
+ UPDATE`, which a write naming the row passes. A hard delete, or an update
634
+ naming a column of a unique index, takes `FOR UPDATE`, which holds that write
635
+ off. A soft delete updates its mark, so it takes `FOR NO KEY UPDATE` unless
636
+ that mark belongs to a unique index, as enrollments' `deletedAt` does, when it
637
+ takes `FOR UPDATE`. `FOR KEY SHARE` lets a named row's other columns change
638
+ before the writer commits. The field's `filterOptions`, the target's read
639
+ access and `publishedWhere` are checked under that same `FOR KEY SHARE`; a
640
+ project whose concurrent writes must not slip past those checks locks the
641
+ row in a step, as courses does. A step judging those columns must lock that row
642
+ itself before the store step, for example with `'noKeyUpdate'`. Taking `FOR UPDATE` after
643
+ the reference check can deadlock with another writer doing the same.
644
+ 7. Field access for the fields the write names.
645
+ 8. The parse and the defaults. Without an `input`, the defaults fill and the
646
+ whole row is parsed; with one, the caller's shape is parsed first and the
647
+ defaults fill after it.
648
+ 9. The service target's `beforeSteps`, the operation's `steps`, then
649
+ `afterSteps` (the operation's own, or else the service target's). At the
650
+ store step the runner answers 404 for a row that was not found, asks field
651
+ access again, checks every relationship value, and refuses a step that wrote
652
+ a `providedBy: 'never'` field.
653
+ 10. The commit. The result is parsed against the row shape and cut to the
654
+ fields the actor may read.
655
+ 11. `afterCommit`, after the commit. A throw there leaves the write standing
656
+ and answers `AfterCommitError`.
657
+
658
+ A nested managed write, such as `tools.of(sessions).create(...)` inside a step,
659
+ joins the outer transaction, so both commit or roll back together.
660
+
661
+ ### Read
662
+
663
+ 1. The depth cap, and the organization reach (`tenant: 'mine'` or `'all'`),
664
+ opened by the outermost read alone.
665
+ 2. Access: the read answer and the permission key. A `Where` narrows every row
666
+ the read reaches.
667
+ 3. The parse, then the operation's steps and its own `afterSteps`. A read runs
668
+ no service-target list and has no `beforeTransaction`.
669
+ 4. Inside the read step: the caller's `where` and `sort` are checked against
670
+ field read access (a filter on a field the actor may not read is a 403), a
671
+ filter across a relationship carries the far kayan's read access, rows a
672
+ soft delete marked or a draft never published are left out, and `join`
673
+ fields are populated through the child kayan's own read.
674
+ 5. Each row is parsed against the row shape and cut to the fields the actor may
675
+ read. A row addressed by id that is absent or hidden answers 404.
676
+
677
+ ### Bulk
678
+
679
+ `updateMany` and `deleteMany` take `{ where, data }`. The bulk's own key is
680
+ asked once; a `where` is required; the match is refused when it holds more than
681
+ `bulkLimit` rows. Each matched row then goes through the single-row write, with
682
+ its own key and field access, all in one transaction: one failing row rolls
683
+ the whole call back. A draft never published is left alone, and an actor who
684
+ may read drafts finds it listed under `errors`. A bulk operation of the
685
+ kayan's own runs `each: '<operation>'` on each row instead, such as an
686
+ `archiveMany` that runs `archive`. Where it lives: `packages/mithaq/src/asas/runner/bulk.ts` `bulk`.
687
+
688
+ ### How access is answered
689
+
690
+ - **Rows.** The access callback, or the row table, answers `true`, `false` or a
691
+ `Where`. A `Where` is ANDed onto the query or the row lookup, so it narrows
692
+ and never widens. A kayan with no access of its own answers only to the
693
+ `auth.admin` kayan's actor, through `auth.admin.access`.
694
+ - **Keys.** Where the actor's keys are graded, an operation also needs
695
+ `<kayan>.<operation>`, and a field needs `<kayan>.fields.<field>`.
696
+ - **Field access.** On read, a field the actor may not read is removed from the
697
+ row. On write, a field a row table denies is a 403 that names it, and a field
698
+ a hand-written callback denies is dropped from the write.
699
+
700
+ Where it lives: `packages/mithaq/src/asas/runner/write.ts` `defaultAuthorize`
701
+ and `projectReadableFields`; `packages/mithaq/src/asas/runner/read.ts`
702
+ `readers`.
703
+
704
+ ### Errors and refusals
705
+
706
+ A refusal the caller caused is an `AsasError` with a status, a message `key` a
707
+ client translates, and `params`:
708
+
709
+ - 400 (`invalid`): the input, a `where`, a reference or an option is wrong.
710
+ - 403 (`denied`): the actor may not do this.
711
+ - 404 (`missing`): no such row. An absent row and one the actor may not reach
712
+ answer alike, so a 404 never says which.
713
+ - 409 (`conflict`): the write collides with a row already there, such as a
714
+ unique value. It never names the value. A transaction aborted by a deadlock or serialization failure answers `error.conflict.retry`, so the caller may try again; core does not retry it.
715
+
716
+ Any other throw is a fault: the HTTP plugs answer it as a 500 with the message
717
+ replaced. `AfterCommitError` means the write committed and an `afterCommit`
718
+ step failed. Where it lives: `packages/mithaq/src/asas/runner/errors.ts`.
719
+
720
+ ## Access and roles
721
+
722
+ ### Keys
723
+
724
+ Every permission key comes from the declarations, and `asas.catalog` lists
725
+ them all:
726
+
727
+ - `<kayan>.<operation>`, one per operation: `courses.find`,
728
+ `courses.archive`;
729
+ - `<kayan>.fields.<field>`, one per field: `courses.fields.title`;
730
+ - `screen.<screen>`, one per screen, which decides who enters it.
731
+
732
+ A key a role names that is not in the catalog fails the build.
733
+
734
+ ### System roles and custom roles
735
+
736
+ The access target (`rbacTarget`) holds two kinds of role:
737
+
738
+ - **System roles** are code: a slug, a label and the keys it holds (`'*'` for
739
+ every key). The template's are `templates/mithaq/src/roles/system-roles.ts` `systemRoles`:
740
+ the backoffice's and the organizations' (`administrator`, `registrar`,
741
+ `instructor`, `student`).
742
+ - **Custom roles** are rows of the `roles` kayan, each organization's own:
743
+ a `name` and a `permissions` list.
744
+
745
+ Which roles an actor holds in an organization are rows of the `memberRoles`
746
+ kayan. The target reads them for the active organization, adds each system
747
+ role's keys from code and each custom role's keys from that organization's
748
+ `roles` rows, and puts the result on the trusted actor. `mithaq rbac assign
749
+ --tenant <id> --user <id> --role <slug>` writes one assignment from the
750
+ command line.
751
+
752
+ <!-- from: templates/mithaq/src/mithaq.ts -->
753
+ ```ts
754
+ const accessOptions = {
755
+ roles: systemRoles,
756
+ rolesOf: assignedRoles,
757
+ access: tenantAccess,
758
+ kayan: { roles: 'roles', memberRoles: 'memberRoles' },
759
+ } as const
760
+
761
+ export const accessTarget = (roles: typeof systemRoles = systemRoles) => {
762
+ const target = rbacTarget({ ...accessOptions, roles })
763
+ // ...
764
+ }
765
+ ```
766
+
767
+ Where it lives: `packages/mithaq/src/rbac/index.ts` `rbacTarget`.
768
+
769
+ ### Row tables
770
+
771
+ A row table says, per role, what each operation may do. Its keys are `'*'`
772
+ (the row every actor assigned a role gets beside its own) and system role
773
+ slugs. An actor with no role takes no row and is denied.
774
+
775
+ ```ts
776
+ access: rows({
777
+ '*': { create: true, read: true, update: { owner: { equals: user.id } }, delete: false },
778
+ }),
779
+ ```
780
+
781
+ Each row answers `create` (only `true` allows), `read`, `update` and `delete`
782
+ with `true`, `false` or a `Where`, and may list `fields` (`'*'`, a name, or
783
+ `!name`). A `false` in any applicable row denies; several `Where` answers admit
784
+ a row any one of them admits. The access target's `access` table is inherited
785
+ by every per-organization kayan; a kayan drops it with `access: { tenant: false
786
+ }`, and a callback written beside the rows is ANDed with them.
787
+
788
+ Where it lives: `packages/mithaq/src/mithaq/rows.ts` `rows`; the template's
789
+ table is `templates/mithaq/src/roles/tenant-access.ts` `tenantAccess`.
790
+
791
+ ## Screens
792
+
793
+ A screen is a target of capability `screen`: one admin app's pages over the
794
+ kayan it lists. It names its path, its kayan, the control and cell that draw
795
+ each kind (`screenKinds` and the project's own), its providers, its own pages,
796
+ and the shells, layouts and components its pages name. A kayan says what the
797
+ screen draws of it under `targets.<screen>`.
798
+
799
+ <!-- from: templates/mithaq/src/mithaq.ts -->
800
+ ```ts
801
+ const shared = {
802
+ controls: screenKinds,
803
+ layouts: {
804
+ app: ui('app-shell.tsx#AppShell'),
805
+ record: ui('record-page.tsx#RecordPage'),
806
+ table: ui('table-page.tsx#TablePage'),
807
+ },
808
+ }
809
+
810
+ export const dashboardScreen = screenTarget({
811
+ ...shared,
812
+ layouts: { ...shared.layouts, top: ui('top-shell.tsx#TopShell') },
813
+ path: '/dashboard',
814
+ kayan: [
815
+ 'courses',
816
+ // ...
817
+ 'tags',
818
+ // ...
819
+ 'settings',
820
+ // ...
821
+ ],
822
+ providers: [
823
+ dashboardUi('theme-provider.tsx#ThemeProvider'),
824
+ ui('rich-text-nodes.tsx#RichTextNodes'),
825
+ // ...
826
+ ],
827
+ pages: {
828
+ dashboard: { path: '/', component: dashboardUi('dashboard.tsx#Dashboard'), permission: 'courses.find' },
829
+ // ...
830
+ },
831
+ // ...
832
+ })
833
+ ```
834
+
835
+ A file is named `ui('file.tsx#Export')` off a folder URL from `folderOf`, and
836
+ a missing file or export fails the build. Where it lives:
837
+ `packages/mithaq/src/screen/index.ts` `screenTarget`.
838
+
839
+ **The admin package.** The controls, cells, forms, lists and shells a screen draws with, `mithaq eject`, and `mithaq upgrade --check`, which compares the files a project holds with them, are described in [The admin package](../mithaq-admin/README.md).
840
+
841
+ **`mithaq generate screens`** writes each screen's route files into the host
842
+ app, and `mithaq screens` prints each screen's page tree. Where it lives:
843
+ `packages/mithaq/src/screen/generate.ts` `run`.
844
+ [The `mithaq` command](src/asas/cli/README.md) describes both commands.
845
+
846
+ ## Extending the language
847
+
848
+ This part covers adding to the language itself: a rukn, a kind or a rule, a target, and a configurable feature. Adding a kayan, a feature on a kayan or a step to a project is in [Adding to the project](../../templates/asas/README.md#adding-to-the-project).
849
+
850
+ ### Add a rukn
851
+
852
+ A rukn is one object made with `defineRukn({ name, kinds, rules, targets, needs })`. It may bring:
853
+
854
+ - **kinds**, each with its own implementation for each target type under `targets`: `drizzle` its column, `zod` its shape, `screen` its control and cell;
855
+ - **rules**;
856
+ - **targets** it builds, which merge with the project's own (the storage rukn builds the storage target over the config it is handed);
857
+ - **needs**, the capabilities it requires of the build, such as `needs: ['store']`. A need names a capability, never a target type, so any store meets `'store'`.
858
+
859
+ A project adds a rukn with one line in `arkan` in `templates/mithaq/src/mithaq.ts`, and one line in `targets` for each capability the rukn needs that the build does not already lay down.
860
+
861
+ ```ts
862
+ import { defineRukn, kind } from 'mithaq'
863
+ import { varchar } from 'mithaq/drizzle/pg-core'
864
+ import { z } from 'zod'
865
+
866
+ const isSlug = (value: unknown): value is string => typeof value === 'string'
867
+
868
+ /** A rukn of this project's own: the `slug` kind, with its column, its shape and how a screen draws it. */
869
+ export const slugs = defineRukn({
870
+ name: 'slugs',
871
+ kinds: {
872
+ slug: kind<string>(isSlug)({
873
+ label: { en: 'Slug', ar: 'معرف نصي' },
874
+ message: { en: 'This is not a slug.', ar: 'هذا ليس معرفا نصيا.' },
875
+ rules: ['required', 'unique', 'index'],
876
+ targets: {
877
+ drizzle: (field: { column: string }) => varchar(field.column, { length: 40 }),
878
+ zod: () => z.string().regex(/^[a-z0-9-]+$/),
879
+ screen: { control: 'text', cell: 'text' },
880
+ },
881
+ }),
882
+ },
883
+ })
884
+
885
+ // templates/mithaq/src/mithaq.ts
886
+ export const arkan = [base, richText({ nodes: richTextNodes }), guardedFiles, versions({ max: 100 }), slugs] as const
887
+ ```
888
+
889
+ A kind's `screen` control and cell each name one the registry ships, a `ui()` reference, or the kind's own name, which the app's admin components map then draws: see [Add or replace a control](../mithaq-admin/README.md#add-or-replace-a-control). A target reads the project's own entry for a kind first, then the kind's own under `targets`, then its shipped table. `buildAsas` refuses a kind, rule or target that two arkan bring, a rukn target under a key the project's `targets` already use, and a rukn whose need no target meets, naming both sides each time.
890
+
891
+ A rukn's kinds, rules and target names are vocabulary, so adding a rukn, or changing what one brings, follows [Add a kind or a rule](#add-a-kind-or-a-rule). Fields a feature needs, such as `uploadFields` or `draftFields`, are not part of a rukn: they are exported beside it as named values a kayan spreads.
892
+
893
+ When you write a rukn factory whose kinds depend on its argument, type that argument as `<const C>`, or the kinds widen and the type checks pass silently. A factory whose kinds do not depend on its argument, such as `richText({ nodes })` or `storage(config)`, takes no generic.
894
+
895
+ ### Add a kind or a rule
896
+
897
+ A kind or a rule is vocabulary, so it changes only with the owner's signature.
898
+
899
+ 1. Ask the owner first. Say what the kayan needs to say and why the existing kinds and rules cannot say it.
900
+ 2. Bring the kind or rule in a rukn, as above. A rule is made with `rule({ accepts, message, excludes })`. Each store and schema target needs an entry for a new rule in its `rules` table, or the build refuses a field that carries it with "has no entry in the ... target's rules". A new kind carries its own entry per target type under `targets`.
901
+ 3. Run `bunx mithaq lock --check`. In a project that keeps a `mithaq.lock`, it turns red and lists each changed path. The red message ends with the sentence that starts with `ميثاق:`.
902
+ 4. Stop there and hand over. The owner runs `bunx mithaq lock`, which signs only when a person types back the code its popup shows. Never sign the lock, approve a signature, or edit `mithaq.lock` by hand: an edited lock fails the check too.
903
+
904
+ A red lock also stops both apps' `dev`, `build` and `preview` scripts. While a project runs without a lock, the vocabulary still changes only with the owner's explicit word. [The `mithaq` command](src/asas/cli/README.md#lock-and-lock---check) describes both commands.
905
+
906
+ ### Add a target
907
+
908
+ A target is one entry in `defineMithaq`'s `targets`, under a name, with a `capability` core knows: `store`, `schema`, `access`, `service`, `screen`, `tenant`, `storage` or `versions`. A target's name and capability are vocabulary, so the owner signs every new one. A new capability is a change to mithaq itself.
909
+
910
+ A target of an existing capability can be the project's own and spreads into `targets` beside the shipped ones. A new target shipped by mithaq follows the order below. Paths from here on are in the mithaq repository.
911
+
912
+ #### What goes where
913
+
914
+ | What it is | Where it lives | How a project uses it |
915
+ |---|---|---|
916
+ | The target's name and capability, a kind's label, message and rules | A rukn the project lays down in `arkan`, recorded in `mithaq.lock` | Only the owner signs a change. |
917
+ | Plain data a project extends: fields, operations, nodes | Core, exported as `default<Name>` | The project spreads it: `{ ...defaultOperations, archive }`, `richText({ nodes: [...defaultNodes, MyNode] })`. |
918
+ | A behaviour a project may replace: a step, a key format, a verify, a serve, a sweep | A `default<Name>` function | The project names it in its own config: `verify: defaultVerify`. |
919
+ | Code that holds policy or talks to the outside world: a provider SDK, a bucket, a mailer, UI | A registry file copied into the project with its marker line | The project edits its copy in place. |
920
+ | An invariant no project may break: access before a read, the tenant from the session, the shape check at the trust boundary, a guard against data loss | Core | Exposed as small building blocks the defaults call, such as `checkReference` and `readForFile`, never as a hidden fallback. |
921
+
922
+ #### The rules
923
+
924
+ 1. **Every default has a name, and the project passes it.** Core never writes `config.verify ?? coreVerify`. A default's name starts with `default`.
925
+ 2. **`buildAsas` refuses a config that leaves a default out**, naming the key. The storage target refuses a config with no `verify`, saying "storage names no "verify" function", and the build refuses a config with no `http` plug.
926
+ 3. **A default that holds policy lives in the project.** What a valid file is, how a key is built and which orphans the sweep deletes are registry files copied to `templates/mithaq/src/<target>/defaults.ts`, not core code.
927
+ 4. **Operations are spread, never injected.** A target ships `<target>Operations` built from exported `default*` steps, and a kayan spreads it beside `defaultOperations`, as `versionOperations` is spread into `courses`.
928
+ 5. **Core keeps only what is not a choice,** and the target's documentation says why each piece core keeps is not the project's. If you cannot write the reason, the piece belongs to the project.
929
+ 6. **Replacing a default must not need core's internals.** The building blocks a default calls are exported, and the default's tests ship beside it.
930
+ 7. **One object argument** for any function a project calls or implements with three or more parameters, or two whose order matters and whose types match. `bun run lint:args` checks the first case.
931
+ 8. **A store that writes at `connect()`** (a push, a seed, a marker) skips that under `connect({ readOnly: true })`, because `generate messages --check` reads through that connect and relies on it writing nothing.
932
+ 9. **A target that holds a secret makes the language module server-only.** Check that no module a browser loads imports it; `apps/client-imports.test.ts` walks the apps for this.
933
+ 10. **A store handles every `lock` mode `find` is handed:** `'update'`, `'noKeyUpdate'`, `'share'` and `'keyShare'`. One written as `lock === 'update' ? … : none` takes no lock for the other three, and the runner's delete guard and `expectedUpdatedAt` rely on them.
934
+
935
+ #### Order
936
+
937
+ 1. **Agree the design with the owner.** Cover the lock change, every `default*` function and value and where it lives (core spread, project config or registry copy), and what core keeps and why.
938
+ 2. **Add the vocabulary:** the target, or the kind in the rukn that brings it, under `packages/mithaq/src/<target>/`. The lock goes red and stays red until the owner signs at the end.
939
+ 3. **Write the core building blocks and their types,** each project-facing function taking one object. Give the target constructor a `type`. If a kayan writes anything under the target, augment `KayanConfigs` with that type's shape, as `packages/mithaq/src/versions/index.ts` does. Export it from `packages/mithaq/src/index.ts`.
940
+ 4. **Write the defaults,** as registry files under `packages/mithaq-admin/registry/<target>/` where they hold policy or talk to the outside world, otherwise as exported `default*` values.
941
+ 5. **Name every function in the template's config,** in `templates/mithaq/src/mithaq.ts` or a file it imports, such as `templates/mithaq/src/storage/index.ts`.
942
+ 6. **Write the tests:** the security cases first, each default's own tests, a test that `buildAsas` refuses the config with each function removed, and a test that a replaced default is called rather than silently ignored. Add a `@ts-expect-error` probe for the rukn to `templates/asas/src/types.probe.ts`.
943
+ 7. **Document it:** what is a default, what core keeps and why, and how to replace each default.
944
+
945
+ Before review, check that:
946
+
947
+ - a search of core for `??` finds no fallback to a behaviour;
948
+ - every function the template's config names starts with `default` or is the project's own;
949
+ - `buildAsas` refuses the config with any one function removed, and a replaced default is called (both tested);
950
+ - `bunx mithaq upgrade --check` lists every copied file;
951
+ - the lock diff holds only the target or kind.
952
+
953
+ ### Add a configurable feature
954
+
955
+ Storage adapters, auth strategies, rich-text nodes, the service target's limits and the HTTP plugs are all configured the same way: the project names every piece, and core supplies named defaults for it to name.
956
+
957
+ - **A list the project extends is spread.** Rich text nodes are `[...defaultNodes, UploadNode]` in `templates/mithaq/src/mithaq/rich-text-nodes.ts`, the one list the server and every editor read. A node that draws React keeps its DOM-free class there and its React subclass in the client file.
958
+ - **A value or a function the project may replace is named.** The service target names `authorize: defaultAuthorize`, `depth: defaultDepth`, `defaultLimit`, `maxLimit: defaultMaxLimit`, `joinLimit: defaultJoinLimit` and `bulkLimit: defaultBulkLimit`. Local auth names `tokenExpiration: defaultTokenExpiration`, `maxLoginAttempts: defaultMaxLoginAttempts` and `lockTime: defaultLockTime`. To change one, put your own value or function in its place.
959
+ - **A default that holds policy or talks to the outside world is a registry file.** The storage adapters (`local`, `r2`, `s3`, `uploadthing`) and `defaults.ts` in `templates/mithaq/src/storage/` are copies of the `mithaq-storage` item. `templates/mithaq/src/storage/index.ts` names each of `defaultKey`, `defaultVerify`, `defaultDisposition`, `defaultSweep`, `defaultUploadInstructions`, `defaultUploaded`, `defaultRemoveFile` and `defaultServeFile`. To add an adapter, write it beside the others and add it to `adapters`; to change a policy, edit `defaults.ts`.
960
+ - **Core never falls back.** A missing piece is refused when the project builds, never filled in. A storage config without `verify`, a build without `http`, or a service target without `authorize` is a configuration error, not a default.
961
+
962
+ Tenancy has no default to replace. Scoping a per-organization kayan to the active organization is not a choice, so core enforces it on every managed operation and nothing turns it off. See [Tenancy](src/asas/runner/README.md).