@drhomet/mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +962 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1056 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -1,9 +1,10 @@
1
1
  // One list in one place, so drizzle's callback transaction is kept as it is.
2
+ import { stepTools } from './store.js';
2
3
  import { rowsOfField } from '../../mithaq/rows.js';
3
4
  import { heldPermissions } from '../../rbac/index.js';
4
5
  import { authActor } from '../actor.js';
5
- import { AfterCommitError, AsasError, denied, invalid, missing } from './errors.js';
6
- import { defaultKinds } from '../../base/kinds.js';
6
+ import { AfterCommitError, AsasError, conflict, denied, invalid, missing, retryConflict } from './errors.js';
7
+ import { defaultKinds, isStorableText } from '../../base/kinds.js';
7
8
  import { providerOf } from '../../base/rules.js';
8
9
  import { atEffect, defaults, issueRefusal, parse, sealOf, storeSeals, validated, withoutPrototype } from './steps.js';
9
10
  import { firstRegisters, running, snapshot } from './runtime.js';
@@ -21,6 +22,9 @@ const known = new Set(operatorList);
21
22
  /** A list of operators as a sentence says it: `"a", "b" and "c"`. */
22
23
  const and = new Intl.ListFormat('en-GB', { type: 'conjunction' });
23
24
  const checkOperatorValue = (said, field, operator, value) => {
25
+ if ([value].flat().some((one) => typeof one === 'string' && !isStorableText(one))) {
26
+ throw new Error(`${said} field "${field}" operator "${operator}" compares text holding a NUL or half a surrogate pair, which no column holds.`);
27
+ }
24
28
  if (operator === 'exists') {
25
29
  if (typeof value !== 'boolean')
26
30
  throw new Error(`${said} field "${field}" operator "exists" needs a boolean.`);
@@ -44,25 +48,44 @@ const checkOperatorValue = (said, field, operator, value) => {
44
48
  };
45
49
  // A second segment is a locale or a related field, never both: `resolve` refuses
46
50
  // `localized` beside `relationTo` on the field itself, whatever the kind is named.
51
+ // A json field's path may run past two segments: its keys are no columns, and Payload names them so.
47
52
  const queryField = ({ kayan, key, said }) => {
48
53
  const parts = key.split('.');
49
- const unsupported = `${said} path "${key}" is unsupported. A path is one field, and may then name one related field of what a relationship points at or one locale of a localized field, so it is never more than two segments.`;
50
- if (parts.length > 2 || parts.some((part) => !part))
51
- throw new Error(unsupported);
54
+ const unsupported = `${said} path "${key}" is unsupported. A path is one field, and may then name one related field of what a relationship points at or one locale of a localized field, so it is never more than two segments, or a key at each segment after a json field.`;
52
55
  const field = parts[0];
53
- const path = parts[1];
54
56
  const resolved = kayan.fields[field];
55
- if (!resolved)
57
+ if ((parts.length > 2 && resolved?.kind !== 'json') || parts.some((part) => !part || !isStorableText(part)))
58
+ throw new Error(unsupported);
59
+ const path = parts[1];
60
+ if (!resolved || !Object.hasOwn(kayan.fields, field))
56
61
  throw new Error(`${said} names unknown field "${key}".`);
62
+ if (resolved.kind === 'json')
63
+ return { field, key, kind: resolved.kind, operators: resolved.operators };
57
64
  if (!path || resolved.locales?.includes(path))
58
65
  return { field, key, kind: resolved.kind, operators: resolved.operators };
59
66
  if (!resolved.target)
60
67
  throw new Error(unsupported);
61
68
  const target = resolved.target.fields[path];
62
- if (!target)
69
+ if (!target || !Object.hasOwn(resolved.target.fields, path))
63
70
  throw new Error(`${said} path "${key}" names unknown related field.`);
71
+ // A path into json across a hop would be a third segment, and a json value compared whole has no store answer.
72
+ if (target.kind === 'json')
73
+ throw new Error(`${said} path "${key}" reaches json field "${path}" across a relationship, and a json field is filtered on its own kayan alone.`);
64
74
  return { field, key, kind: target.kind, operators: target.operators };
65
75
  };
76
+ /** Raw store filters keep their adapter's value and empty-filter semantics; only declared paths are checked here. */
77
+ export const assertStoreWhere = (kayan, where) => {
78
+ for (const [key, value] of Object.entries(where)) {
79
+ if (key === 'and' || key === 'or') {
80
+ if (Array.isArray(value))
81
+ for (const one of value)
82
+ assertStoreWhere(kayan, one);
83
+ }
84
+ else
85
+ queryField({ kayan, key, said: `Kayan "${kayan.name}" store filter` });
86
+ }
87
+ return where;
88
+ };
66
89
  // An access constraint and a screen's `when` share this one grammar.
67
90
  export const assertFieldWhere = ({ said, key, kind, operators, value, valued = true }) => {
68
91
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -92,6 +115,13 @@ export const assertFieldWhere = ({ said, key, kind, operators, value, valued = t
92
115
  throw new Error(`${said} field "${key}" operator "${operator}" only supports numeric, date and datetime fields.`);
93
116
  }
94
117
  checkOperatorValue(said, key, operator, operand);
118
+ if (!valued && Array.isArray(operand) && operand.length > whereLimits.values) {
119
+ throw new Error(`${said} field "${key}" operator "${operator}" lists at most ${whereLimits.values} values.`);
120
+ }
121
+ // Each word of a `like` is a pattern the store matches, so its words are held to the same count.
122
+ if (!valued && operator === 'like' && typeof operand === 'string' && operand.trim().split(/\s+/).length > whereLimits.values) {
123
+ throw new Error(`${said} field "${key}" operator "like" holds at most ${whereLimits.values} words.`);
124
+ }
95
125
  // A value an integer or decimal column cannot hold would reach Postgres as a 500.
96
126
  // A caller's own query skips this: its row shape refuses values with a path each.
97
127
  if (valued && (kind === 'integer' || kind === 'decimal') && operator !== 'exists') {
@@ -110,7 +140,13 @@ export const assertFieldWhere = ({ said, key, kind, operators, value, valued = t
110
140
  export const assertWhere = (kayan, where) => normalizedWhere({ kayan, where, valued: true, said: `Kayan "${kayan.name}" access constraint` });
111
141
  // Grammar alone: a caller's values are the row shape's to refuse.
112
142
  export const assertWhereGrammar = (kayan, where) => normalizedWhere({ kayan, where, valued: false, said: `Kayan "${kayan.name}" query` });
113
- const normalizedWhere = ({ kayan, where, valued, said }) => {
143
+ /**
144
+ * How large a caller's `where` may be: the values one `in` or `not_in` lists, and the fields, `and`s and
145
+ * `or`s it holds in all, however nested. Each is a condition the store runs, so past these a read is
146
+ * refused before it reaches the store rather than holding it for minutes.
147
+ */
148
+ export const whereLimits = { values: 1000, nodes: 200 };
149
+ const normalizedWhere = ({ kayan, where, valued, said, nodes = { left: whereLimits.nodes } }) => {
114
150
  if (!where || typeof where !== 'object' || Array.isArray(where)) {
115
151
  throw new Error(`${said} is not an object of fields.`);
116
152
  }
@@ -120,11 +156,14 @@ const normalizedWhere = ({ kayan, where, valued, said }) => {
120
156
  // No prototype, so `__proto__` is a key like any other.
121
157
  const normalized = Object.create(null);
122
158
  for (const [key, value] of entries) {
159
+ // An access constraint is the project's own to size; a caller's is held to the limits.
160
+ if (!valued && --nodes.left < 0)
161
+ throw new Error(`${said} holds at most ${whereLimits.nodes} fields, "and"s and "or"s.`);
123
162
  if (key === 'and' || key === 'or') {
124
163
  if (!Array.isArray(value) || !value.length) {
125
164
  throw new Error(`${said} "${key}" must be a non-empty array.`);
126
165
  }
127
- normalized[key] = value.map((one) => normalizedWhere({ kayan, where: one, valued, said }));
166
+ normalized[key] = value.map((one) => normalizedWhere({ kayan, where: one, valued, said, nodes }));
128
167
  continue;
129
168
  }
130
169
  const queried = queryField({ kayan, key, said });
@@ -138,6 +177,111 @@ const normalizedWhere = ({ kayan, where, valued, said }) => {
138
177
  export const aliveWhere = (kayan) => kayan.softDelete ? { [kayan.softDelete]: { exists: false } } : undefined;
139
178
  // A row made as a draft and never published is read by nothing but a draft read.
140
179
  export const publishedWhere = (kayan) => combineWhere(aliveWhere(kayan), kayan.versions?.drafts ? { _status: { equals: 'published' } } : undefined);
180
+ /** Every field of `kayans` whose `onDelete` keeps a row of `target` while it names it. */
181
+ export const restrictingPointers = (kayans, target) => kayans.flatMap((kayan) => Object.values(kayan.fields)
182
+ .filter((field) => field.kind !== 'join' && field.target?.name === target.name && field.rules?.onDelete === 'restrict')
183
+ .map((field) => ({ kayan, field })));
184
+ /** How many of the rows that still name a row the refusal names by title; its count says the rest. */
185
+ const namedUses = 5;
186
+ const byId = { field: 'id', direction: 'asc' };
187
+ /**
188
+ * The ids of the rows of each kayan that name `id` through `pointers`, a row whose latest version
189
+ * names it among them, since the foreign key holds live rows alone and a pending draft would
190
+ * otherwise keep an id nothing answers for. A pointing kayan that is tenant-owned is read within
191
+ * `tenant` when one is given, so a count never reaches another organization's rows.
192
+ */
193
+ export const referencingIds = async ({ id, pointers, store, tenant, tx }) => {
194
+ const uses = new Map();
195
+ for (const { kayan, field } of pointers) {
196
+ const ids = uses.get(kayan) ?? new Set();
197
+ const within = (path) => (kayan.tenant && tenant !== undefined ? { [path]: { equals: tenant } } : undefined);
198
+ const rows = await store.find({ kayan: kayan.name, args: { limit: 0, page: 1, sort: byId, where: combineWhere({ [field.name]: { equals: id } }, within(kayan.tenant)) }, tx });
199
+ for (const row of rows.docs)
200
+ ids.add(String(row.id));
201
+ if (kayan.versions) {
202
+ // ponytail: `version.<field>` is read with no index, a scan of the versions table per delete;
203
+ // index it once a delete is measured slow.
204
+ const versions = await store.find({ kayan: kayan.versions.table, args: { limit: 0, page: 1, sort: byId, where: combineWhere({ and: [{ latest: { equals: true } }, { [`version.${field.name}`]: { equals: id } }] }, within(`version.${kayan.tenant}`)) }, tx });
205
+ for (const version of versions.docs)
206
+ ids.add(String(version.parent));
207
+ }
208
+ if (ids.size)
209
+ uses.set(kayan, ids);
210
+ }
211
+ return uses;
212
+ };
213
+ /**
214
+ * Refuses the delete while a live row or the latest version of a row names it, in the delete's own
215
+ * transaction. The delete holds the row `FOR UPDATE`, and a write naming it holds it `FOR KEY SHARE`
216
+ * in `assertReferences`, a draft save included. The two conflict, so no new reference lands between
217
+ * this count and the delete. An update holds its own row as `rowLock` says, which `FOR KEY SHARE` passes
218
+ * unless the update names a unique column. The refusal counts the actor's own
219
+ * organization's rows and names by title only those the actor reads; a row another organization's
220
+ * rows name is refused with no count at all.
221
+ */
222
+ const refuseReferenced = async ({ kayan, existing, pointers, store, tenant, tx, authorize, admin, actor }) => {
223
+ const counted = async (within) => {
224
+ const found = await referencingIds({ id: existing.id, pointers, store, tenant: within, tx });
225
+ // A row that names itself goes with itself; read off the stored row, since the caller's id may differ in case.
226
+ found.get(kayan)?.delete(String(existing.id));
227
+ return { found, total: [...found.values()].reduce((sum, ids) => sum + ids.size, 0) };
228
+ };
229
+ // Every organization's rows gate the delete, since the foreign key holds no draft; only the
230
+ // actor's own are counted and named to it.
231
+ const all = await counted(undefined);
232
+ if (!all.total)
233
+ return;
234
+ const own = tenant === undefined ? all : await counted(tenant);
235
+ if (own.total !== all.total) {
236
+ throw conflict({
237
+ message: `A "${kayan.name}" row is still named by rows this caller does not see, so it is not deleted.`,
238
+ key: 'error.conflict.referenced',
239
+ params: { kayan: kayan.name },
240
+ });
241
+ }
242
+ const uses = [];
243
+ for (const [one, ids] of own.found) {
244
+ if (ids.size)
245
+ uses.push({ by: one.name, count: ids.size, ...(await titlesOf({ kayan: one, ids: [...ids], store, tx, authorize, admin, actor })) });
246
+ }
247
+ throw conflict({
248
+ message: `A "${kayan.name}" row is still named by ${uses.map(({ by, count }) => `${count} "${by}" row${count === 1 ? '' : 's'}`).join(' and ')}, so it is not deleted.`,
249
+ key: 'error.conflict.referenced',
250
+ params: { kayan: kayan.name, by: uses[0].by, uses },
251
+ });
252
+ };
253
+ /**
254
+ * The titles of the rows among `ids` this actor finds and whose title field it reads, a draft only
255
+ * where it reads drafts. A title that points at another row is its raw id, so such rows are
256
+ * counted alone; a moment is sent as a value, marked so the screen formats it.
257
+ */
258
+ const titlesOf = async ({ kayan, ids, store, tx, authorize, admin, actor }) => {
259
+ const title = Object.values(kayan.targets ?? {}).map((target) => target?.useAsTitle).find((one) => typeof one === 'string');
260
+ const field = title && Object.hasOwn(kayan.fields, title) ? kayan.fields[title] : undefined;
261
+ if (!field || field.target || field.kind === 'join')
262
+ return { titles: [] };
263
+ const asked = (operation) => authorize({ admin, actor, data: {}, effect: 'read', kayan, operation }).then((where) => ({ where }), (thrown) => {
264
+ if (thrown instanceof AsasError && thrown.status === 403)
265
+ return undefined;
266
+ throw thrown;
267
+ });
268
+ const readable = await asked('find');
269
+ if (!readable)
270
+ return { titles: [] };
271
+ const drafts = kayan.versions?.drafts ? await asked('findVersions') : undefined;
272
+ const shown = kayan.versions?.drafts && drafts
273
+ ? combineWhere(aliveWhere(kayan), drafts.where && { or: [{ _status: { equals: 'published' } }, drafts.where] })
274
+ : publishedWhere(kayan);
275
+ const sort = field.rules?.localized ? byId : { field: field.name, direction: 'asc' };
276
+ const found = await store.find({ kayan: kayan.name, args: { limit: namedUses, page: 1, sort, where: { and: [{ id: { in: ids } }, readable.where, shown].filter(Boolean) } }, tx });
277
+ const titles = [];
278
+ for (const row of found.docs) {
279
+ const visible = await projectReadableFields({ kayan, row, actor });
280
+ if (Object.hasOwn(visible, field.name))
281
+ titles.push(visible[field.name]);
282
+ }
283
+ return { titles, ...(field.kind === 'date' || field.kind === 'datetime' ? { titleKind: field.kind } : {}) };
284
+ };
141
285
  const fieldAccess = async (operation, kayan, data, actor, id, named, doc, kept = []) => {
142
286
  const next = { ...data };
143
287
  const refused = [];
@@ -227,12 +371,29 @@ export const assertSingleConstrained = (kayan, where) => {
227
371
  throw new Error(`Global "${kayan.name}" keeps the tenant field, and the actor's access constrains no rows, so no one row is the tenant's own. Return a Where from its read and update access that constrains the rows to the actor's tenant.`);
228
372
  }
229
373
  };
230
- const loadExisting = async (kayan, store, id, where, tx, rows = aliveWhere(kayan)) => {
374
+ /**
375
+ * The lock a write holds on a row it changes, as Postgres's own statement takes it: `FOR UPDATE` for a
376
+ * delete, or for an update naming a column of a unique index, which holds off a write naming the row
377
+ * `FOR KEY SHARE`, and `FOR NO KEY UPDATE` for any other update, which that write passes. A soft delete
378
+ * is an update of its mark, and asks no rows naming its row, so it has no reference to hold off. Taken
379
+ * whole when the row loads, before any value is compared: an UPDATE raising its lock later would wait on
380
+ * a writer that named the row in between while that writer waits on it, a deadlock.
381
+ *
382
+ * ponytail: publishing a latest draft's unique column can still raise this lock; take `update` for every
383
+ * non-draft update on a kayan with drafts and a unique index if that path needs to be covered.
384
+ * ponytail: an update step can raise its own row's lock by writing a unique column or hard-deleting the
385
+ * row; take `update` at load for an operation whose steps may do either if that path needs to be covered.
386
+ */
387
+ export const rowLock = ({ kayan, kind, data }) => {
388
+ const written = kind === 'remove' || kind === 'deleteMany' ? (kayan.softDelete ? [kayan.softDelete] : undefined) : Object.keys(data);
389
+ return !written || kayan.indexes.some((index) => index.unique && index.fields.some((name) => written.includes(name))) ? 'update' : 'noKeyUpdate';
390
+ };
391
+ const loadExisting = async ({ kayan, store, id, where, tx, rows = aliveWhere(kayan), lock }) => {
231
392
  assertSingleConstrained(kayan, where);
232
393
  const found = await store.find({ kayan: kayan.name, args: {
233
394
  limit: 1,
234
395
  page: 1,
235
- lock: 'update',
396
+ lock,
236
397
  // A write's own look at the row it changes, over a set an id or a constraint already reduced.
237
398
  sort: { field: 'id', direction: 'asc' },
238
399
  // A marked row is not there to change, so a second delete of it is the 404 an absent id gets.
@@ -251,6 +412,11 @@ const validateTenantRelationships = async (kayan, store, data, actor, existing,
251
412
  tenant: own ? (data[own] ?? existing?.[own]) : undefined,
252
413
  // Asked with no operation, so no permission key is asked for.
253
414
  access: (target) => authorize({ admin, actor, data: {}, effect: 'read', kayan: target }),
415
+ // A field's `filterOptions` holds a save to what its picker offers, as Payload's does.
416
+ offered: async (field) => {
417
+ const answer = await field.filterOptions?.({ user: actor, tx });
418
+ return answer === undefined || answer === true ? undefined : answer === false ? { id: { in: [] } } : assertWhere(field.target, answer);
419
+ },
254
420
  tx,
255
421
  });
256
422
  };
@@ -259,9 +425,12 @@ const validateTenantRelationships = async (kayan, store, data, actor, existing,
259
425
  * organization than the written row's, or one `access` leaves out. A step's own raw write
260
426
  * passes no `access`, so it is held to the organization alone.
261
427
  */
262
- export const assertReferences = async ({ kayan, store, data, tenant, access, tx }) => {
428
+ // Postgres answers a uuid in lowercase, so ids are compared in that case.
429
+ const keyIn = (kayan) => (value) => kayan.fields.id?.kind === 'uuid' && typeof value === 'string' ? value.toLowerCase() : value;
430
+ export const assertReferences = async ({ kayan, store, data, tenant, access, offered, tx }) => {
263
431
  const own = kayan.tenant;
264
- // Gathered per target, so two fields at one kayan and a whole `hasMany` set are one read.
432
+ // Gathered per target, so two fields at one kayan and a whole `hasMany` set are one read; a
433
+ // field whose options are its own is read apart.
265
434
  const targets = new Map();
266
435
  for (const field of Object.values(kayan.fields)) {
267
436
  // The ownership column is the runner's, or was judged above. A join takes no write.
@@ -270,13 +439,16 @@ export const assertReferences = async ({ kayan, store, data, tenant, access, tx
270
439
  const given = data[field.name];
271
440
  if (given === undefined || given === null)
272
441
  continue;
442
+ const key = offered && field.filterOptions ? field : field.target;
273
443
  for (const value of field.hasMany ? given : [given]) {
274
444
  if (value === undefined || value === null)
275
445
  continue;
276
- targets.set(field.target, [...(targets.get(field.target) ?? []), { field: field.name, value }]);
446
+ const held = targets.get(key) ?? { target: field.target, sets: [] };
447
+ held.sets.push({ field: field.name, value });
448
+ targets.set(key, held);
277
449
  }
278
450
  }
279
- for (const [target, sets] of targets) {
451
+ for (const [key, { target, sets }] of targets) {
280
452
  const far = target.tenant;
281
453
  // A raw write to a shared row names it as it likes; the foreign key holds it.
282
454
  if (!access && !far)
@@ -312,18 +484,19 @@ export const assertReferences = async ({ kayan, store, data, tenant, access, tx
312
484
  // Fail closed before the store is asked, so the foreign key cannot tell which ids exist.
313
485
  throw refuse(sets[0].field);
314
486
  }
315
- // Postgres answers a uuid in lowercase, so ids are compared in that case.
316
- const uuid = target.fields.id?.kind === 'uuid';
317
- const keyOf = (value) => (uuid && typeof value === 'string' ? value.toLowerCase() : value);
487
+ const keyOf = keyIn(target);
318
488
  const ids = [...new Set(sets.map(({ value }) => keyOf(value)))];
319
489
  const found = await store.find({ kayan: target.name, args: {
320
490
  limit: ids.length,
321
- lock: 'update',
491
+ // The lock a foreign key takes: it holds off the row's delete, and never another write naming the
492
+ // row or a plain update of its other columns, so those neither wait on it nor deadlock with it.
493
+ // See "Create, update and delete" in packages/mithaq/README.md for checks that need a stronger lock.
494
+ lock: 'keyShare',
322
495
  page: 1,
323
496
  sort: { field: 'id', direction: 'asc' },
324
497
  where: combineWhere(combineWhere(combineWhere({ id: { in: ids } }, admits), tenanted),
325
498
  // Nothing a plain read shows may point at a row no plain read shows; a step's own write may.
326
- access ? publishedWhere(target) : undefined),
499
+ combineWhere(access ? publishedWhere(target) : undefined, key === target ? undefined : await offered(key))),
327
500
  }, tx });
328
501
  const admitted = new Set(found.docs.map((row) => keyOf(row.id)));
329
502
  const refused = sets.find(({ value }) => !admitted.has(keyOf(value)));
@@ -418,17 +591,19 @@ const outsideStore = (kayan, operation, slot) => {
418
591
  const refuse = () => {
419
592
  throw new Error(`Kayan "${kayan.name}" operation "${operation}" reached the store from "${slot}", which runs outside the operation's transaction, so the write would not be in it. Write through tools.kayans, which opens its own, or move the step into "steps".`);
420
593
  };
421
- return Object.freeze({ insert: refuse, remove: refuse, update: refuse });
594
+ return Object.freeze({ find: refuse, hold: refuse, upsert: refuse, sum: refuse, insert: refuse, remove: refuse, update: refuse });
422
595
  };
423
596
  // No transaction is carried, so a managed write here opens its own. An `afterCommit`
424
597
  // throw is never an `AsasError`: the write it would refuse has committed.
425
598
  export const runOutside = async ({ kayan, tools, slot, list, start, around, }) => {
426
- const given = Object.freeze({ ...tools, store: outsideStore(kayan, tools.operation, slot) });
599
+ const given = stepTools({ ...tools, store: outsideStore(kayan, tools.operation, slot) });
600
+ // Read before the first step, which is handed `start` itself and may rewrite it in place.
601
+ const { tenant } = start;
427
602
  return running.run(around, async () => {
428
603
  let ctx = start;
429
604
  try {
430
605
  for (const step of list)
431
- ctx = await step(ctx, given);
606
+ ctx = { ...(await step(ctx, given)), tenant };
432
607
  }
433
608
  catch (cause) {
434
609
  if (slot === 'beforeTransaction')
@@ -464,7 +639,7 @@ const namesKey = (schema, key) => {
464
639
  }
465
640
  return !!answer.issues?.some(({ path: [first] = [] }) => String(typeof first === 'object' ? first.key : first) === key);
466
641
  };
467
- export const write = ({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, }) => async (data = {}, options) => {
642
+ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin, tenantOf, pointers = [], }) => async (data = {}, options) => {
468
643
  const list = atEffect(declaration.steps);
469
644
  const { actor, context, entry, open } = beginWrite({ kayan, operation: tools.operation, store, cap, options });
470
645
  // The id reaches the store before the operation's parse, so it is held to the shape at
@@ -546,7 +721,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
546
721
  tools,
547
722
  slot: 'beforeTransaction',
548
723
  list: atEffect(declaration.beforeTransaction),
549
- start: { kayan, data: { ...data }, actor, user: actor, tx: undefined, context },
724
+ start: { kayan, data: { ...data }, actor, user: actor, tx: undefined, context, tenant: owner },
550
725
  around: { context, user: actor },
551
726
  });
552
727
  values = before.data;
@@ -556,7 +731,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
556
731
  const run = (tx) => {
557
732
  entry.tx = tx;
558
733
  return running.run({ context, transactions: new Map(open).set(store, entry), user: actor }, async () => {
559
- const existing = tools.kind === 'insert' ? undefined : await loadExisting(kayan, store, values.id, accessWhere, tx, rowsWhere);
734
+ const existing = tools.kind === 'insert' ? undefined : await loadExisting({ kayan, store, id: values.id, where: accessWhere, tx, rows: rowsWhere, lock: rowLock({ kayan, kind: tools.kind, data: values }) });
560
735
  // An actor who may not read this row's drafts writes onto it as it is, so nothing tells
561
736
  // it a draft is pending.
562
737
  const hidesDrafts = !!versions?.drafts && !!existing && (!admits || (!draftsAsked && !!admits.where
@@ -565,7 +740,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
565
740
  // A row found without the constraint means the caller cannot address it (404); none means
566
741
  // the upsert. A per-tenant global is left out: the unconstrained look would find another tenant's row.
567
742
  if (kayan.single && !kayan.tenant && tools.kind === 'update' && !existing && accessWhere
568
- && await loadExisting(kayan, store, values.id, undefined, tx)) {
743
+ && await loadExisting({ kayan, store, id: values.id, where: undefined, tx, lock: 'noKeyUpdate' })) {
569
744
  throw missing({
570
745
  message: `No "${kayan.name}" row the access constraint admits.`,
571
746
  key: 'error.not_found.global',
@@ -593,7 +768,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
593
768
  : values;
594
769
  // Carried from a draft rather than sent, so field access refuses rather than drops them.
595
770
  const carried = merged === values ? [] : Object.keys(merged).filter((key) => !(key in values));
596
- const pending = asIs && newest && !restoring && base?._status === 'draft' ? base : undefined;
771
+ let pending = asIs && newest && !restoring && base?._status === 'draft' ? base : undefined;
597
772
  // A key the operation's own shape would refuse is refused here first, in that shape's words.
598
773
  if (drafting) {
599
774
  const stray = Object.keys(data).find((key) => key !== 'id' && (Object.hasOwn(kayan.fields, key) ? providerOf(kayan.fields[key], fieldOperation) !== 'client' : !declaration.input || !namesKey(tools.schema, key)));
@@ -619,38 +794,83 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
619
794
  const inputData = fieldOperation
620
795
  ? await fieldAccess(fieldOperation, kayan, given, actor, targetId, tools.operation, existing, carried)
621
796
  : given;
622
- // The audit sink writes through the same three, so only the kayan's own writes save versions.
797
+ // The audit sink writes through the same three, so only the kayan's own writes save versions,
798
+ // through the store itself: a versions table is no kayan, and the seal refuses it to a step.
623
799
  const versioned = versions ? kayan : undefined;
624
800
  const own = (name) => versioned && name === kayan.name ? versioned : undefined;
801
+ // The row this write authorized, which alone takes its access, versions and soft delete, in
802
+ // whatever case the caller sent its uuid. A step naming any other id writes that row.
803
+ const keyOf = keyIn(kayan);
804
+ const ownRow = (name, id) => kind !== 'insert' && name === kayan.name && (keyOf(id) === keyOf(targetId) || (existing !== undefined && id === existing.id));
805
+ // And a raw write of this kayan never reaches the authorized row, however its id is spelled.
806
+ const elsewhere = (args) => kind !== 'insert' && args.kayan === kayan.name && targetId !== undefined ? { ...args, where: combineWhere(args.where, { id: { not_equals: targetId } }) } : args;
625
807
  const protectedStore = Object.freeze({
808
+ find: tools.store.find,
809
+ hold: tools.store.hold,
810
+ sum: tools.store.sum,
811
+ upsert: async (args) => {
812
+ const kept = own(args.kayan);
813
+ if (drafting && kept)
814
+ throw invalid({ message: "An upsert writes stored rows, not this operation's draft snapshot. Use update for that draft.", key: 'error.invalid.value', params: { kayan: args.kayan } });
815
+ const row = await tools.store.upsert(args);
816
+ const current = ownRow(args.kayan, row.id);
817
+ if (kept && !(current && pending && status === 'draft'))
818
+ await saveVersion({ kayan: kept, store, writes: store, tx: args.tx, row, autosave: false });
819
+ if (kept && current && pending) {
820
+ pending = { ...pending, ...Object.fromEntries(Object.keys(args.data).map((field) => [field, row[field]])), id: targetId, _status: 'draft' };
821
+ await saveVersion({ kayan: kept, store, writes: store, tx: args.tx, row: pending, autosave: false });
822
+ }
823
+ return row;
824
+ },
626
825
  insert: async ({ tx: heldTx, kayan: name, data: values }) => {
627
826
  const row = await tools.store.insert({ tx: heldTx, kayan: name, data: values });
628
827
  const kept = own(name);
629
828
  if (kept)
630
- await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row, autosave: autosaving });
829
+ await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row, autosave: autosaving });
631
830
  return row;
632
831
  },
633
- update: async ({ tx: heldTx, kayan: name, id, data: values }) => {
832
+ update: async (args) => {
833
+ const { tx: heldTx, kayan: name, id, data: values } = args;
834
+ // Any other row is the step's own raw write, held by the tenant seal alone.
835
+ if (!ownRow(name, id))
836
+ return tools.store.update(elsewhere(args));
634
837
  const kept = own(name);
838
+ const incremented = Object.keys(values).filter((field) => kayan.fields[field]?.kind !== 'json' && values[field] && typeof values[field] === 'object' && '$inc' in values[field]);
839
+ if (drafting && kept && incremented.length)
840
+ throw invalid({ message: 'An increment writes a stored integer field, not a draft snapshot.', key: 'error.invalid.value', params: { kayan: name } });
635
841
  if (drafting && kept) {
636
842
  const draft = { ...base, ...values, id: targetId };
637
- await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row: draft, autosave: autosaving });
843
+ await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row: draft, autosave: autosaving });
638
844
  return draft;
639
845
  }
640
- const row = await tools.store.update({ tx: heldTx, kayan: name, id: targetId ?? id, data: values, where: writeWhere });
846
+ const row = await tools.store.update({ tx: heldTx, kayan: name, id: targetId, data: values, where: writeWhere });
641
847
  // A never-published row is its own draft, so the draft appended below is its one version.
642
848
  if (kept && !(pending && status === 'draft'))
643
- await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row, autosave: false });
849
+ await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row, autosave: false });
644
850
  // Appended after the row's own, so the draft stays the latest.
645
- if (kept && pending)
646
- await saveVersion({ kayan: kept, store, writes: tools.store, tx: heldTx, row: { ...pending, ...values, id: targetId, _status: 'draft' }, autosave: false });
851
+ if (kept && pending) {
852
+ pending = { ...pending, ...values, ...Object.fromEntries(incremented.map((field) => [field, row[field]])), id: targetId, _status: 'draft' };
853
+ await saveVersion({ kayan: kept, store, writes: store, tx: heldTx, row: pending, autosave: false });
854
+ }
647
855
  return row;
648
856
  },
649
857
  // A soft delete keeps the `remove` seal, so the effect, the access answer and the 404 stay
650
858
  // the delete's; the mark is refused as a field any caller supplies.
651
- remove: ({ tx: heldTx, kayan: name, id }) => kayan.softDelete
652
- ? tools.store.update({ tx: heldTx, kayan: name, id: targetId ?? id, data: { [kayan.softDelete]: new Date() }, where: writeWhere })
653
- : tools.store.remove({ tx: heldTx, kayan: name, id: targetId ?? id, where: writeWhere }),
859
+ remove: async (args) => {
860
+ const { tx: heldTx, kayan: name, id } = args;
861
+ if (!ownRow(name, id))
862
+ return tools.store.remove(elsewhere(args));
863
+ if (kayan.softDelete)
864
+ return tools.store.update({ tx: heldTx, kayan: name, id: targetId, data: { [kayan.softDelete]: new Date() }, where: writeWhere });
865
+ // A marked row is kept, so only a delete that removes it answers to the rows naming it.
866
+ // An update step can remove its own row, so exclude new references before counting them.
867
+ // This raises its lock; the upgrade path is taking `update` at load for such an operation.
868
+ if (pointers.length && existing && kind !== 'remove')
869
+ await store.find({ kayan: name, args: { limit: 1, page: 1, lock: 'update', sort: byId, where: { id: { equals: existing.id } } }, tx: heldTx });
870
+ if (pointers.length && existing)
871
+ await refuseReferenced({ kayan, existing, pointers, store, tenant: kayan.tenant ? existing[kayan.tenant] : tenantOf(actor), tx: heldTx, authorize, admin, actor });
872
+ return tools.store.remove({ tx: heldTx, kayan: name, id: targetId, where: writeWhere });
873
+ },
654
874
  });
655
875
  // Its absence is a build fault: the partial update shape would make a row without its required fields.
656
876
  if (kind !== tools.kind && !tools.insertSchema) {
@@ -667,13 +887,14 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
667
887
  store: protectedStore,
668
888
  where: writeWhere,
669
889
  });
670
- let ctx = { kayan, data: { ...inputData }, actor, user: actor, tx, context, dataBefore, dataAfter: dataBefore };
890
+ const publicTools = stepTools(runTools);
891
+ let ctx = { kayan, data: { ...inputData }, actor, user: actor, tx, context, dataBefore, dataAfter: dataBefore, tenant: owner };
671
892
  // With no `input` the filled row is parsed, so a default reaches a required column; with one,
672
893
  // the caller's strict shape is parsed first. A remove writes no column, so neither runs.
673
- const fill = (one) => (fieldOperation ? defaults(one, runTools) : one);
894
+ const fill = (one) => (fieldOperation ? defaults(one, publicTools) : one);
674
895
  ctx = declaration.input
675
- ? fill(await parse({ schema: runTools.schema, ctx, tools: runTools }))
676
- : await parse({ schema: runTools.schema, ctx: fill(ctx), tools: runTools });
896
+ ? fill(await parse({ schema: runTools.schema, ctx, tools: publicTools }))
897
+ : await parse({ schema: runTools.schema, ctx: fill(ctx), tools: publicTools });
677
898
  // Filled after the parse, from the session alone.
678
899
  if (kind === 'insert' && kayan.tenant)
679
900
  ctx = { ...ctx, data: { ...ctx.data, [kayan.tenant]: owner } };
@@ -696,7 +917,11 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
696
917
  });
697
918
  }
698
919
  if (kind !== 'insert' && ctx.data.id !== targetId) {
699
- throw new Error(`Kayan "${kayan.name}" operation "${tools.operation}" cannot change the authorized target id.`);
920
+ throw conflict({
921
+ message: `Kayan "${kayan.name}" operation "${tools.operation}" cannot change the authorized target id.`,
922
+ key: 'error.conflict.target',
923
+ params: { kayan: kayan.name, operation: tools.operation },
924
+ });
700
925
  }
701
926
  if (fieldOperation) {
702
927
  ctx = { ...ctx, data: await fieldAccess(fieldOperation, kayan, ctx.data, actor, targetId, tools.operation, existing, carried) };
@@ -735,7 +960,7 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
735
960
  if (kayan.single)
736
961
  ctx = { ...ctx, data: { ...ctx.data, globalType: kayan.name } };
737
962
  }
738
- ctx = await step(ctx, runTools);
963
+ ctx = { ...(await step(ctx, publicTools)), tenant: owner };
739
964
  }
740
965
  committed = ctx;
741
966
  // A draft may be short of `required`, so it leaves through the draft shape, which holds it to its kinds alone.
@@ -774,6 +999,9 @@ export const write = ({ kayan, declaration, tools, store, cap, authorize, admin,
774
999
  try {
775
1000
  result = await store.transaction(run);
776
1001
  }
1002
+ catch (thrown) {
1003
+ throw retryConflict(thrown);
1004
+ }
777
1005
  finally {
778
1006
  entry.settled = true;
779
1007
  }
@@ -6,6 +6,20 @@ export type BetterAuthAccessOptions<Roles extends SystemRoles, Table extends Row
6
6
  statements: Readonly<Record<string, readonly string[]>>;
7
7
  }>>;
8
8
  };
9
+ /**
10
+ * The access target over Better Auth's own `member.role` and nothing else: a member holds the system
11
+ * roles its membership row names in that organization, comma-separated, and there is no custom role
12
+ * and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
13
+ * The row is read from the store for the organization asked about, never off the session, so a
14
+ * membership elsewhere grants nothing here.
15
+ *
16
+ * A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
17
+ * is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
18
+ * named in it would otherwise grant its keys inside the tenant.
19
+ */
20
+ export declare const memberRoleTarget: <const Roles extends SystemRoles, const Table extends RowTable, Actor>({ roles, rolesOf, access, }: Omit<AccessOptions<Roles, Table, Actor>, 'kayan'>) => AccessTarget<Roles, Table, Actor> & {
21
+ readonly type: 'memberRole';
22
+ };
9
23
  /**
10
24
  * The access target over Better Auth's store: a custom role is an `organizationRole` row.
11
25
  * Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
@@ -29,6 +29,38 @@ const readPermission = (value) => {
29
29
  }
30
30
  return parsed;
31
31
  };
32
+ /**
33
+ * The access target over Better Auth's own `member.role` and nothing else: a member holds the system
34
+ * roles its membership row names in that organization, comma-separated, and there is no custom role
35
+ * and no assignment table. It suits a project whose roles are the provider's owner, admin and member.
36
+ * The row is read from the store for the organization asked about, never off the session, so a
37
+ * membership elsewhere grants nothing here.
38
+ *
39
+ * A `member.role` slug is one a tenant's admin can mint, since the provider's dynamic access control
40
+ * is on, so `roles` holds only slugs a tenant may hold, or `rolesOf` drops the rest: a backoffice role
41
+ * named in it would otherwise grant its keys inside the tenant.
42
+ */
43
+ export const memberRoleTarget = ({ roles, rolesOf, access, }) => ({
44
+ capability: 'access',
45
+ type: 'memberRole',
46
+ roles,
47
+ ...(rolesOf ? { rolesOf } : {}),
48
+ access: (access ?? {}),
49
+ // Better Auth's own model and columns.
50
+ reads: ['member'],
51
+ resolve: async ({ store, tenant, member, actor }) => {
52
+ // A read without a tenant would be every tenant's; the actor still goes through `rolesOf`, which
53
+ // answers backoffice staff, who have no active organization, their backoffice roles.
54
+ const found = scoped(tenant) && scoped(member)
55
+ ? await allRows({ store, kayan: 'member', where: { and: [{ organizationId: { equals: tenant } }, { userId: { equals: member } }] } })
56
+ : { docs: [] };
57
+ // Split the way the provider's own owner checks split it: exact, untrimmed, so ` owner` is no owner.
58
+ const slugs = [...new Set(found.docs.flatMap((row) => (typeof row.role === 'string' ? row.role.split(',').filter(Boolean) : [])))];
59
+ const held = heldSlugs({ roles, rolesOf, slugs, actor });
60
+ const { everything, keys } = systemKeys(roles, held);
61
+ return { permissions: everything ? '*' : [...keys], roles: held };
62
+ },
63
+ });
32
64
  /**
33
65
  * The access target over Better Auth's store: a custom role is an `organizationRole` row.
34
66
  * Read directly, not through `hasPermission`, whose cache never invalidates a narrowed role.
@@ -8,9 +8,11 @@ export declare const header = "// Written by `mithaq generate auth` from the Bet
8
8
  * Writes one kayan file per Better Auth table, and one barrel beside them. With `check`, writes
9
9
  * nothing and returns the files a run would change or delete. A file without the header is never touched.
10
10
  */
11
- export declare const generateAuth: ({ options, directory, check, provider, providers, screens, schemaName, }: {
11
+ export declare const generateAuth: ({ options, directory, language, check, provider, providers, screens, schemaName, }: {
12
12
  options: Parameters<typeof getAuthTables>[0];
13
13
  directory: URL;
14
+ /** The module each generated kayan imports `defineKayan` from: `generate.language`, as given. */
15
+ language: string;
14
16
  check?: boolean;
15
17
  /** The Postgres schema every kayan of this instance is built in, from its `providers` entry. */
16
18
  schemaName?: string;