@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,472 @@
1
+ # Tenancy
2
+
3
+ mithaq keeps each organization's rows apart. A kayan (an entity or collection)
4
+ whose fields include the organization field belongs to one organization per
5
+ row, and core holds every managed operation on it to the organization the
6
+ caller's session has active. A kayan without that field is shared by every
7
+ organization.
8
+
9
+ The organizations are Better Auth's: the `organization` kayan of the
10
+ provider's organization plugin, with its `member` rows. This page covers the
11
+ setup, what core enforces and why it cannot be turned off, how a read reaches
12
+ further, and what is refused. Read [the language reference](../../../README.md) first if the
13
+ words kayan, rukn, asas, target or operation are new.
14
+
15
+ ## Setup
16
+
17
+ 1. **Lay down the tenant target** in `defineMithaq`'s `targets`, under the key
18
+ `tenant`:
19
+
20
+ <!-- from: templates/mithaq/src/mithaq.ts -->
21
+ ```ts
22
+ tenant: betterAuthTenant({
23
+ relationTo: 'organization',
24
+ onDelete: 'restrict',
25
+ newRows: 'active',
26
+ organizations,
27
+ backoffice: ({ user }) => onBackoffice() && backofficeRolesOf(user).length > 0,
28
+ }),
29
+ ```
30
+
31
+ `relationTo` names the kayan an organization is. `onDelete: 'restrict'` is
32
+ the only value it takes: an organization that still holds a row is not
33
+ deleted. `organizations` is required; see [Organization modes](#organization-modes). It is
34
+ new: a language that lays down `betterAuthTenant` without it is refused, so a project made
35
+ before it adds `organizations: 'many'`, the behaviour it had.
36
+ `backoffice` is optional; see [The backoffice app](#the-backoffice-app).
37
+
38
+ 2. **Export the field.** The language returns the target as `targets: {
39
+ tenant }`, and its `field` is what a kayan places. The template exports a
40
+ labelled copy, under its own word for a tenant:
41
+
42
+ <!-- from: templates/mithaq/src/mithaq.ts -->
43
+ ```ts
44
+ export const { defineKayan, defineGlobal, buildAsas, rows, user, generate, locales, targets, targets: { tenant } } = language
45
+
46
+ export const organization = { ...tenant.field, label: { en: 'Organization', ar: 'المؤسسة' } }
47
+ ```
48
+
49
+ 3. **Build with one Better Auth kayan.** The provider's kayan (`user`,
50
+ `organization`, `member` and the rest, written by `mithaq generate auth`)
51
+ go in the `buildAsas` kayan list. A build with any per-organization kayan
52
+ carries one Better Auth kayan, and a second is refused, because its sessions
53
+ name the organizations the field points at.
54
+
55
+ 4. **Lay down an access target** for roles per organization. The template's
56
+ `rbacTarget` reads each member's roles from `memberRoles` and each
57
+ organization's custom roles from `roles`, and both kayan place
58
+ `organization`. `tenant: 'mine'` needs it.
59
+
60
+ 5. **Place the field** in each kayan that belongs to an organization, then run
61
+ `mithaq migrate` to add the column.
62
+
63
+ Where it lives: `packages/mithaq/src/auth/better-auth/tenant.ts`
64
+ `betterAuthTenant`; the template's setup is `templates/mithaq/src/mithaq.ts`
65
+ `serverTargets`.
66
+
67
+ ## The field and its binding
68
+
69
+ A kayan is per-organization when it carries a field bound to the tenant target.
70
+ The binding is the field's `targets: { tenant: true }`, which `tenant.field`
71
+ already carries:
72
+
73
+ <!-- from: templates/features/tags/tags/tags.ts -->
74
+ ```ts
75
+ export const tags = defineKayan({
76
+ name: 'tags',
77
+ // ...
78
+ fields: {
79
+ ...defaultFields,
80
+ organization,
81
+ name: {
82
+ kind: 'text',
83
+ label: { en: 'Name', ar: 'الاسم' },
84
+ rules: { localized: true, required: true, trim: true, maxLength: 60, index: true },
85
+ },
86
+ },
87
+ })
88
+ ```
89
+
90
+ `tenant.field` is a relationship to one `organization` row with these rules:
91
+ `required`, `index`, `providedBy: { create: 'application', update: 'never' }`
92
+ and `onDelete: 'restrict'`. Its column is named after the field,
93
+ `organization_id` for a field called `organization`, with a foreign key that
94
+ restricts.
95
+
96
+ A field written by hand works too. It takes every rule of the target's field
97
+ that it leaves out:
98
+
99
+ ```ts
100
+ organization: {
101
+ kind: 'relationship',
102
+ relationTo: 'organization',
103
+ label: { en: 'Organization', ar: 'المؤسسة' },
104
+ targets: { tenant: true },
105
+ },
106
+ ```
107
+
108
+ A bound field may change its copy (label, description), add `unique`, and say
109
+ how a screen draws it under `targets.<screen>`. Nothing else about it can
110
+ change; see [What is refused](#what-is-refused).
111
+
112
+ A kayan that places no bound field is shared: every organization reads it
113
+ through its own access, and its rows belong to none. There is no per-kayan
114
+ opt-out and no list of per-organization kayan: the field alone decides. (A
115
+ kayan's `access: { tenant: false }` drops the row table it would inherit from
116
+ the access target. It does not touch the organization scope.)
117
+
118
+ ## What core enforces
119
+
120
+ On every managed operation of a per-organization kayan, from the HTTP plugs,
121
+ from `asas.kayan.<name>.operations` in the app, and from a step's nested call,
122
+ core does all of this:
123
+
124
+ - **The scope is the session's active organization.** It is the
125
+ `activeOrganizationId` of the actor's Better Auth session, from the build's
126
+ one Better Auth kayan, and it counts only when the provider lists the actor as
127
+ a member of that organization. Anything else has no scope: an anonymous
128
+ caller, a local-auth account, a session with no active organization, a
129
+ session naming an organization the actor is not a member of, and a session of
130
+ another Better Auth instance. Each is refused with a 403 before any step runs.
131
+ It fails closed: no scope never means no filter.
132
+ - **A create is filled from the scope.** The runner writes the field after the
133
+ parse, from the session. A caller who sends the field is refused with a 400
134
+ (`Unrecognized key`) before any step runs.
135
+ - **Reads, updates and deletes are ANDed with `organization = active`**, on
136
+ top of whatever the kayan's access answers. This covers `find`, `findByID`,
137
+ `count`, the row an update or delete loads, a bulk write's matched rows, a
138
+ filter across a relationship to a per-organization kayan, and every level of
139
+ `join` population.
140
+ - **Another organization's row answers like a missing one.** A read by id, an
141
+ update and a delete of it are the same 404 (`error.not_found.row`) an absent
142
+ id gets, so the answer never says the id exists.
143
+ - **A delete's refusal counts the organization's own rows.** A row that any
144
+ organization's rows or latest drafts still name is not deleted. The refusal
145
+ counts and names those of the deleted row's organization, or of the actor's
146
+ active one for a shared row; a shared row that another organization's rows
147
+ name is refused with no count, so the answer never says how much of another
148
+ organization uses it.
149
+ - **The field never changes.** It is `providedBy: { update: 'never' }`. A
150
+ step that writes it on an update, or writes another organization into it on a
151
+ create, fails, so nothing moves a row to another organization.
152
+ - **References stay in the organization.** Every relationship and upload
153
+ value a write names, single or `hasMany`, must point at a row the actor may
154
+ read, and one that points at a per-organization kayan must name a row of the
155
+ written row's organization. Another organization's row is refused with the
156
+ same 400 an absent id gets.
157
+ - **Versions, uploads and globals are scoped.** A version is read and restored
158
+ through its row's own access, and a snapshot never moves its row to another
159
+ organization. An upload is signed for the active organization and served
160
+ through the row's read. A global that places the field is one row per
161
+ organization.
162
+ - **A step's raw store is sealed.** `tools.store.find`, `hold`, `sum`, `upsert`,
163
+ `update` and `remove` are ANDed with `organization = active`. `insert` and
164
+ `upsert` fill the active organization and refuse any other or no active
165
+ organization. `update` and `remove` may not name the field.
166
+ A relationship value a raw write names must point at the active
167
+ organization's row. This holds whichever per-organization kayan the step
168
+ names, not only the operation's own. A raw write reaches exactly the id it
169
+ names; only the operation's own row takes its access, versions and soft
170
+ delete. A raw write names a kayan; a versions or join table is core's and
171
+ is refused. `upsert` requires plain key values for exactly a declared unique
172
+ index in `on`; `on: ['id']` works without an extra index. Upserting the
173
+ operation's own versioned kayan while saving a draft is refused. An update
174
+ of the operation's own row refuses arithmetic in a draft snapshot.
175
+
176
+ `ctx.tx` is the store's transaction itself. Raw SQL through it is the
177
+ project's own privileged path, which core does not scope; see
178
+ [Privileged paths](#privileged-paths).
179
+
180
+ ### Why it cannot be bypassed
181
+
182
+ - The scope wraps the build's access evaluator, whichever one the service
183
+ target names, and it is asked before the project's own callback. A row table,
184
+ an access callback answering `true`, a custom `authorize` or a replaced step
185
+ list can narrow what an operation reaches and never widen it.
186
+ - The organization comes from the trusted snapshot the auth strategy took of
187
+ the session, not from the actor object. Changing `activeOrganizationId` on
188
+ the object moves nothing.
189
+ - The only store a step is handed is the sealed one. `ctx.tx` is the raw way
190
+ past it, and that is the project's own.
191
+ - A nested managed call runs under the scope of the call around it, and a read's
192
+ reach is opened by the outermost read alone, so a call inside it cannot widen
193
+ what it reaches.
194
+
195
+ Where it lives: `packages/mithaq/src/asas/runner/tenant.ts` `tenancy` (with
196
+ `scoped`, `sealWrites` and `activeTenant`), `writeTenant` there, and
197
+ `packages/mithaq/src/asas/runner/write.ts` `assertReferences`. The tests are
198
+ `templates/asas/src/tenant-scope.test.ts` and
199
+ `templates/asas/src/tenant-target.test.ts`.
200
+
201
+ ## Reading further: `tenant: 'mine' | 'all'`
202
+
203
+ A read may ask to reach past the active organization with the `tenant` option.
204
+ In the app it is the second argument; over HTTP it is `?tenant=` on a read
205
+ route.
206
+
207
+ ```ts
208
+ const page = await asas.kayan.courses.operations.find({ limit: 20 }, { actor, tenant: 'mine' })
209
+ ```
210
+
211
+ ```
212
+ POST /api/courses/find?tenant=mine
213
+ ```
214
+
215
+ - **`'mine'`** reads every organization the actor is a verified member of. Each
216
+ membership is judged under the roles the actor holds in that organization: a
217
+ membership whose roles may not read the kayan adds nothing, and each row comes
218
+ back cut to the fields that membership may read. A filter or a sort on a field
219
+ any reached membership may not read is a 403. A membership whose roles fail to
220
+ resolve holds no key, adds nothing, and the failure goes to `auth.onError`.
221
+ It needs a Better Auth session and an access target; without either it is a
222
+ 403, and so is a read no membership may make.
223
+ - **`'all'`** reads every organization. Only backoffice staff in the trusted
224
+ backoffice app may ask it: the actor must be an authenticated one and the
225
+ target's `backoffice` callback must answer `true`. Anyone else gets a 403.
226
+
227
+ Both are for reads alone:
228
+
229
+ - A `where` on the organization field only narrows: `{ organization: { equals:
230
+ 'org2' } }` reaches org2 only where the reach already includes it.
231
+ - A write always lands in the active organization. A `tenant` option on a
232
+ create, update, delete or bulk write is a 400 (`error.invalid.tenant_option`).
233
+ - A global refuses the option with the same 400: its read is the active
234
+ organization's row.
235
+ - So does a build with no tenant target, and any value other than `'mine'` or
236
+ `'all'`.
237
+
238
+ ## The backoffice app
239
+
240
+ `'all'` is meant for an app that serves backoffice staff and nothing else. The
241
+ `backoffice` callback decides who that is, and the project decides which app is
242
+ trusted: the answer must come from the app's own facts, never from the request.
243
+
244
+ The template does it like this:
245
+
246
+ - `apps/asas-backoffice` runs every request it serves inside `asBackoffice`, an
247
+ `AsyncLocalStorage` that nothing a client sends can set. `onBackoffice()`
248
+ answers `true` there and nowhere else.
249
+ - Backoffice roles (`super_admin`, `support`, `explorer`) live on Better Auth's
250
+ `user.role`, never in an organization's `memberRoles`. The template's
251
+ `assignedRoles` answers them in the backoffice app alone, and the tenant app
252
+ never answers them.
253
+ - `backoffice: ({ user }) => onBackoffice() && backofficeRolesOf(user).length > 0`.
254
+ - The backoffice screen's reads of a kayan pass `tenant: 'all'`; a global's read
255
+ and every write pass nothing.
256
+ - The rows give every backoffice role `read: true` and `false` for every write,
257
+ so staff read every organization's rows and write none.
258
+
259
+ Where it lives: `templates/mithaq/src/mithaq/auth/index.ts` `asBackoffice` and `onBackoffice`;
260
+ `templates/mithaq/src/roles/tenant-access.ts` `assignedRoles` and `backofficeReads`;
261
+ `apps/asas-backoffice/src/screens.tsx` `inProcess`.
262
+
263
+ ## Organization modes
264
+
265
+ `organizations` tells core how many organizations the project holds:
266
+
267
+ - `'many'`: any number.
268
+ - `'one'`: one. Whether the project shows it by name is the project's own setting, which core never reads.
269
+
270
+ Tenancy is the same in both: every per-organization kayan places the field, and the scope above is ANDed onto every managed operation. So switching between them moves no row.
271
+
272
+ What core does with the value: in `'one'`, `connect()` reads the tenant kayan after the stores connect, and refuses to start when it holds more than one row. The message names the mode. A `connect({ readOnly: true })` does not check.
273
+
274
+ Two organizations in a one-organization project would split its people and rows without a word. Leaving `'many'` therefore needs a merge first: re-key the field on every per-organization table and resolve what each organization held once (a `within` unique, a global's row, roles). That merge is a migration of the project's own.
275
+
276
+ What the project does with the value: sign-up, joining and the screens follow it in the template's auth setup (`templates/mithaq/src/mithaq/auth/organizations.ts`). Core reads nothing there.
277
+
278
+ ## Unique values and indexes
279
+
280
+ On a per-organization kayan, a field's `unique: true` means unique within the
281
+ organization. Its default index is `(organization, field)`, so another
282
+ organization may hold the same value and never learns it is taken. On a shared
283
+ kayan the same rule is unique across the table.
284
+
285
+ A second row with a value the organization already holds is a 409
286
+ (`error.conflict.unique`) whose message and params name no value.
287
+
288
+ A kayan's `indexes` is a list, or a function of the defaults:
289
+
290
+ - Left out, the kayan gets the defaults: one unique index per `unique` field.
291
+ - A list is added to the defaults.
292
+ - `({ defaults }) => [...]` answers the whole list, so it can drop or replace a
293
+ default.
294
+
295
+ A group expands to indexes led by the organization field:
296
+
297
+ ```ts
298
+ indexes: [{ within: 'organization', unique: ['code', ['code', 'term']], index: ['startsAt'] }],
299
+ ```
300
+
301
+ This builds unique `(organization, code)`, unique `(organization, code, term)`
302
+ and a plain index `(organization, startsAt)`. Each entry of `unique` and
303
+ `index` is a field or a list of fields. `within` must name the kayan's bound
304
+ field, and a group takes `within`, `unique` and `index` alone and names at
305
+ least one index.
306
+
307
+ A plain entry means exactly what it says. `{ fields: ['code'], unique: true }`
308
+ on a per-organization kayan is unique across every organization, on purpose:
309
+ use it for a value that must be unique everywhere.
310
+
311
+ ```ts
312
+ indexes: ({ defaults }) => [
313
+ ...defaults.filter((index) => !index.fields.includes('code')),
314
+ { fields: ['code'], unique: true },
315
+ ],
316
+ ```
317
+
318
+ Two more rules hold:
319
+
320
+ - A `unique` field that no unique index covers is refused, so dropping a
321
+ default without replacing it fails the build.
322
+ - A file kayan's storage `key` stays unique across the table, even on a
323
+ per-organization kayan: a key is the address of one object in a store every
324
+ organization shares.
325
+
326
+ Where it lives: `packages/mithaq/src/mithaq/resolve.ts`, the `indexes` handling
327
+ after `uniqueRuleIndexes`.
328
+
329
+ ## Storage
330
+
331
+ A per-organization kayan that holds files keeps each organization's files
332
+ apart:
333
+
334
+ - `POST /api/<kayan>/uploadInstructions` asks the create access of the kayan
335
+ under the scope, so an actor with no active organization is refused with a
336
+ 403.
337
+ - The upload reference is signed with the active organization. A create that
338
+ sends a reference signed for another organization, another kayan or another
339
+ hour is refused with a 400 (`error.invalid.upload_reference`).
340
+ - The storage config's `key` function is handed the organization. The
341
+ template's `defaultKey` writes `<prefix>/<organization>/<kayan>/<id>/<filename>`,
342
+ and `_` in place of the organization for a shared kayan.
343
+ - Every key must hold the row id as a whole path segment, and sit under the
344
+ adapter's prefix where it has one, whatever the project's `key` function
345
+ answers.
346
+ - `GET /api/<kayan>/file/<id>` reads the row through the kayan's own
347
+ `findByID` as the actor, so another organization's file answers the same 404
348
+ as an absent one.
349
+ - `public: true` is refused on a per-organization file kayan: a public address
350
+ would hand one organization's file to anyone who has it.
351
+
352
+ A shared kayan's files belong to no organization, whichever session uploads
353
+ them.
354
+
355
+ Where it lives: `packages/mithaq/src/storage/storage.ts` `signReference`,
356
+ `checkReference` and `resolveStorage`; `templates/mithaq/src/storage/defaults.ts`
357
+ `defaultKey`.
358
+
359
+ ## Globals
360
+
361
+ A global that places the bound field is one row per organization. Write the
362
+ field `unique` and `globalType` not unique:
363
+
364
+ <!-- from: templates/features/settings/settings/settings.ts -->
365
+ ```ts
366
+ export const settings = defineGlobal({
367
+ name: 'settings',
368
+ fields: {
369
+ ...defaultFields,
370
+ organization: { ...organization, rules: { ...organization.rules, unique: true } },
371
+ // ...
372
+ contactEmail: { kind: 'email', label: { en: 'Contact email', ar: 'بريد التواصل' } },
373
+ globalType: { ...defaultGlobalFields.globalType, rules: { required: true, defaultValue: 'settings' } },
374
+ },
375
+ // ...
376
+ targets: {
377
+ service: { operations: defaultGlobalOperations },
378
+ // ...
379
+ },
380
+ })
381
+ ```
382
+
383
+ - `findOne({}, { actor })` answers the active organization's row, or `null`
384
+ before its first write.
385
+ - `update(data, { actor })` writes it, and the first update makes it.
386
+ - Two concurrent first writes of one organization make one row; the other is
387
+ the 409 a unique value gets.
388
+ - With no active organization both are a 403, and a `tenant` option is a 400.
389
+
390
+ `buildAsas` refuses the field without `unique`, a `unique` `globalType`, and a
391
+ global that drops the unique index over the field alone. The template's global
392
+ is `templates/features/settings/settings/settings.ts` `settings`.
393
+
394
+ ## What is refused
395
+
396
+ `buildAsas`, or the target itself, refuses each of these with a message that
397
+ names the fix:
398
+
399
+ - `onDelete` other than `'restrict'`. `cascade` would delete rows in the
400
+ database, where no step runs and no file or audit follows them; `set null`
401
+ would leave rows no organization owns or can read.
402
+ - Two bound fields in one kayan: a row belongs to one organization.
403
+ - A bound field where the language lays down no tenant target.
404
+ - A tenant target under any key but `tenant`, or another capability under
405
+ `tenant`.
406
+ - A kayan that writes `targets.tenant` itself: the field decides, not the
407
+ kayan.
408
+ - A store or schema target's entry on the bound field, such as
409
+ `targets.drizzle`, which would build the column in place of the tenant's
410
+ rules. A screen's entry is the only other one it takes.
411
+ - A bound field that loosens the binding: `hasMany`, another `relationTo`, a
412
+ missing `required` or `index`, another `providedBy`, another `onDelete`, a
413
+ `defaultValue`, or `targets.tenant` set to anything but `true`.
414
+ - A field that looks like the tenant field but is unbound: a relationship to
415
+ the organization kayan filled by the application on create and never after,
416
+ with no `targets.tenant`. This is what a spread of `tenant.field` with its own
417
+ `targets` produces, and it would leave the kayan shared.
418
+ - A per-organization kayan with `auth`: an account is one identity across every
419
+ organization, found by its email and tokens alone.
420
+ - More than one Better Auth kayan in a build with any per-organization kayan.
421
+ - `storage.public: true` on a per-organization file kayan.
422
+ - A per-organization global whose field is not `unique`, whose `globalType` is
423
+ `unique`, or that drops the unique index over the field.
424
+ - A `within` group on a shared kayan, or within any field but the bound one.
425
+
426
+ At run time:
427
+
428
+ - A `tenant` option on a write or a global, or a value other than `'mine'` or
429
+ `'all'`: 400.
430
+ - The bound field sent by a caller: 400.
431
+ - An actor with no verified active organization: 403.
432
+ - `'all'` from anyone but backoffice staff in the backoffice app: 403.
433
+
434
+ ## What is not supported
435
+
436
+ - **Public sites per organization.** An anonymous caller has no organization, so
437
+ every operation of a per-organization kayan refuses it. A public page reads a
438
+ shared kayan, or reads through the project's own server code.
439
+ - **Writes across organizations.** A write lands in the active organization
440
+ alone. To act in another organization, switch the session's active
441
+ organization first.
442
+
443
+ ## Privileged paths
444
+
445
+ Core scopes managed operations. These paths are not managed operations, and
446
+ whoever writes them scopes them:
447
+
448
+ - **Raw SQL through `ctx.tx`.** A step's `ctx.tx` is the store's own
449
+ transaction, and a query through it sees every organization. Name the
450
+ organization in the query yourself. The template's
451
+ `templates/features/roles/member-roles/member-roles.ts` `managerRemains` does it:
452
+
453
+ ```ts
454
+ const tx = ctx.tx as NodePgDatabase
455
+ await tx.execute(sql`select id, user_id as "user", role from member_roles where organization_id = ${tenant}`)
456
+ ```
457
+
458
+ Prefer a managed call, `tools.of(kayan)`, wherever one does the job: it
459
+ carries the scope, the access and the transaction.
460
+ - **The store outside any operation**, such as `asas.targets.drizzle.db`. It
461
+ reads and writes every organization.
462
+ - **Seeds.** The template's `templates/asas/src/seed.ts` writes every row
463
+ through the build's own operations, with a real session whose active
464
+ organization is the one it fills, so the scope applies as it does to a person.
465
+ The few raw updates it makes name `organization_id` themselves.
466
+ - **Migrations.** `mithaq migrate` writes the migrations, and applying them
467
+ runs outside the runner. A migration that moves data names the organization
468
+ itself.
469
+ - **Commands.** `mithaq rbac assign --tenant <id> --user <id> --role <slug>`
470
+ writes one assignment directly, after checking the user is a member of that
471
+ organization. `mithaq storage sweep` reads every row of every file kayan to
472
+ find objects no row names.
@@ -0,0 +1,39 @@
1
+ # Migrations in production
2
+
3
+ In production, `connect()` on the asas, the runtime `buildAsas` builds, does not push the schema. It checks the migrations instead, as `prodMigrations` on `drizzleAdapter` says. This page covers what its two settings do, the lock every writer of the migration history takes, and applying migrations by hand. Setting `prodMigrations` in a project is in [`connect()` at start](../../../../templates/asas/README.md#connect-at-start).
4
+
5
+ ## `pending: 'refuse'` or `'run'`
6
+
7
+ | | `'refuse'` (the template's choice) | `'run'` |
8
+ | --- | --- | --- |
9
+ | A migration is pending | The app refuses to start, names the pending folders, and exits 1. Nothing is served and nothing is written. | The app applies every pending migration in one transaction, then serves. If one fails, none is applied. |
10
+ | Who applies migrations | Your deploy, as a separate step before the apps start. | The first app instance that starts. |
11
+ | Use it when | You want schema changes to be a deliberate, visible step, reviewed or run by a person or a pipeline. | You deploy one artifact and want it to bring its own schema, and you accept that starting the app changes the database. |
12
+
13
+ With either choice, a production start also refuses:
14
+
15
+ - a database with the dev-push marker;
16
+ - a database whose history is still in drizzle's default table, `drizzle.__drizzle_migrations`. Run `mithaq migrate` against it once to move the history, then start again.
17
+
18
+ ## The advisory lock
19
+
20
+ Every writer of the migration history takes one Postgres advisory lock on `drizzle.__mithaq_migrations` (`pg_advisory_xact_lock`). This covers a production start with `pending: 'run'`, a development push, and the step that creates the history table. If several instances start at once with `'run'`, one applies the migrations and the others wait, then find nothing left to apply.
21
+
22
+ ## Applying migrations by hand
23
+
24
+ With `pending: 'refuse'`, you apply migrations with drizzle-kit, using the version the installed mithaq depends on, and the config `mithaq migrate` wrote:
25
+
26
+ ```sh
27
+ cd templates/asas
28
+ set -a; . ./.env; set +a # or export DATABASE_URL from your secret store
29
+ DRIZZLE_KIT=$(node -p "path.join(path.dirname(require('module').createRequire(fs.realpathSync('node_modules/mithaq/package.json')).resolve('drizzle-kit')), 'bin.cjs')")
30
+ node "$DRIZZLE_KIT" migrate --config src/migrations/drizzle.config.ts
31
+ ```
32
+
33
+ The config reads `DATABASE_URL` through the project's own `asas`, so the migration reaches the same database the app uses.
34
+
35
+ > **Warning:** `drizzle-kit migrate` takes no lock. Do not run it while an app is starting, and never while an instance configured with `pending: 'run'` may start, or both can apply the same migration. Run it, wait for it to finish, then start the apps.
36
+
37
+ If a migration's SQL was already run by hand, record it instead of applying it again. `mithaq migrate` prints the exact `insert` line for each unapplied folder. Record only a migration whose SQL really ran.
38
+
39
+ > ponytail: a migration builds an index with a plain `CREATE INDEX`, which blocks writes to its table while it runs, such as the indexes Better Auth asks for on `session`, `account`, `verification`, `member` and `invitation`. On a table of millions of rows, build each one by hand first with `CREATE INDEX CONCURRENTLY`, under the exact name the migration gives it, then record that migration as above instead of applying it.