@adula/kit 0.2.0-alpha.4 → 1.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/README.md +12 -2
  2. package/build/agent/AGENTS.template.md +2 -2
  3. package/build/agent/capabilities.md +54 -7
  4. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  5. package/build/agent/skills/idea-review/SKILL.md +26 -1
  6. package/build/agent/skills/module-review/SKILL.md +22 -0
  7. package/build/agent/skills/perf-review/SKILL.md +24 -0
  8. package/build/agent/skills/schema-review/SKILL.md +22 -0
  9. package/build/agent/skills/security-review/SKILL.md +24 -0
  10. package/build/agent/skills/ui-review/SKILL.md +22 -0
  11. package/build/commands/capabilities.d.ts +4 -0
  12. package/build/commands/capabilities.js +35 -4
  13. package/build/commands/doctor.js +37 -1
  14. package/build/commands/gaps.js +16 -6
  15. package/build/commands/install.js +19 -5
  16. package/build/commands/main.d.ts +4 -2
  17. package/build/commands/main.js +2 -0
  18. package/build/commands/module_add.js +2 -2
  19. package/build/commands/resource.js +1 -1
  20. package/build/commands/resource_snapshot.d.ts +15 -0
  21. package/build/commands/resource_snapshot.js +61 -0
  22. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  23. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  24. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  25. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  26. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  27. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  28. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  29. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  30. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  31. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  32. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  33. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  34. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  35. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  36. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  37. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  38. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  39. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  40. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  41. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  42. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  43. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  44. package/build/index.d.ts +31 -2
  45. package/build/index.js +17 -2
  46. package/build/src/admin/contracts.d.ts +7 -0
  47. package/build/src/admin/contracts.js +34 -12
  48. package/build/src/admin/controller.d.ts +2 -0
  49. package/build/src/admin/controller.js +52 -1
  50. package/build/src/admin/presentation.d.ts +6 -0
  51. package/build/src/admin/record_title.d.ts +14 -0
  52. package/build/src/admin/record_title.js +50 -0
  53. package/build/src/admin/resource_service.d.ts +184 -2
  54. package/build/src/admin/resource_service.js +713 -48
  55. package/build/src/attachments/attachment_service.d.ts +12 -0
  56. package/build/src/attachments/attachment_service.js +28 -2
  57. package/build/src/attachments/upload_grants.d.ts +44 -0
  58. package/build/src/attachments/upload_grants.js +105 -0
  59. package/build/src/auth/ability.d.ts +1 -1
  60. package/build/src/auth/ability.js +4 -1
  61. package/build/src/auth/actor_store.js +6 -1
  62. package/build/src/auth/conditions.d.ts +15 -0
  63. package/build/src/auth/conditions.js +36 -0
  64. package/build/src/auth/sql.js +6 -2
  65. package/build/src/collaboration/assignments.d.ts +129 -0
  66. package/build/src/collaboration/assignments.js +333 -0
  67. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  68. package/build/src/collaboration/record_collaboration.js +348 -0
  69. package/build/src/commands/agent_assets.js +5 -0
  70. package/build/src/commands/capabilities.d.ts +19 -0
  71. package/build/src/commands/capabilities.js +179 -0
  72. package/build/src/commands/doctor.d.ts +31 -0
  73. package/build/src/commands/doctor.js +114 -0
  74. package/build/src/commands/gap_report.d.ts +50 -2
  75. package/build/src/commands/gap_report.js +102 -4
  76. package/build/src/commands/generator.js +3 -3
  77. package/build/src/commands/snapshot.d.ts +28 -0
  78. package/build/src/commands/snapshot.js +48 -0
  79. package/build/src/commands/source_markers.d.ts +10 -1
  80. package/build/src/commands/source_markers.js +36 -4
  81. package/build/src/core/administration_guard.js +3 -1
  82. package/build/src/core/message_templates.d.ts +83 -0
  83. package/build/src/core/message_templates.js +293 -0
  84. package/build/src/core/module_seed.d.ts +15 -0
  85. package/build/src/core/module_seed.js +31 -0
  86. package/build/src/core/notifications.d.ts +18 -1
  87. package/build/src/core/notifications.js +27 -1
  88. package/build/src/core/roles.d.ts +27 -1
  89. package/build/src/core/roles.js +133 -5
  90. package/build/src/database/schema.d.ts +45 -0
  91. package/build/src/database/schema.js +290 -0
  92. package/build/src/eslint/index.js +26 -0
  93. package/build/src/events/outbox.d.ts +1 -0
  94. package/build/src/events/outbox.js +1 -1
  95. package/build/src/events/record_mutation.d.ts +11 -0
  96. package/build/src/events/record_mutation.js +15 -2
  97. package/build/src/integrations/imports.d.ts +74 -0
  98. package/build/src/integrations/imports.js +333 -0
  99. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  100. package/build/src/integrations/inbound_webhooks.js +276 -0
  101. package/build/src/integrations/openapi.d.ts +39 -0
  102. package/build/src/integrations/openapi.js +323 -0
  103. package/build/src/integrations/print.d.ts +37 -0
  104. package/build/src/integrations/print.js +124 -0
  105. package/build/src/integrations/webhooks.d.ts +98 -0
  106. package/build/src/integrations/webhooks.js +298 -0
  107. package/build/src/resource/define_resource.d.ts +1 -0
  108. package/build/src/resource/define_resource.js +21 -1
  109. package/build/src/resource/registry.d.ts +3 -0
  110. package/build/src/resource/registry.js +42 -0
  111. package/build/src/resource/types.d.ts +67 -1
  112. package/build/src/resource/values.js +2 -1
  113. package/build/src/services/settings.d.ts +13 -1
  114. package/build/src/services/settings.js +9 -2
  115. package/build/src/workflows/define_workflow.d.ts +124 -0
  116. package/build/src/workflows/define_workflow.js +123 -0
  117. package/build/src/workflows/engine.d.ts +141 -0
  118. package/build/src/workflows/engine.js +752 -0
  119. package/build/stubs/resource_contract.txt +103 -41
  120. package/package.json +7 -3
@@ -2,9 +2,12 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
2
2
  import { isAbsolute, join, relative } from 'node:path';
3
3
  import { KIT_VERSION } from '../version.js';
4
4
  import { agentAssets, managedRules, digest } from './agent_assets.js';
5
+ import { columnName } from '../resource/define_resource.js';
5
6
  // The deployment volume and backup scripts share this application-relative path.
6
7
  const UPLOADS_PATH = 'storage/uploads';
7
8
  const UPLOADS_WARNING_BYTES = 5_000_000_000;
9
+ /** An event the worker has not published within this age suggests that no worker runs. */
10
+ const OUTBOX_STALE_MS = 60_000;
8
11
  async function assertContained(root, path) {
9
12
  const resolved = relative(await realpath(root), await realpath(path));
10
13
  if (resolved === '..' ||
@@ -259,3 +262,114 @@ export async function diagnose(root, settings, production, env, now = Date.now()
259
262
  findings.push(...(await diagnoseUi(root)));
260
263
  return findings;
261
264
  }
265
+ /** The columns and indexes createResourceTable derives from a definition, one entry each. */
266
+ function tableShape(resource) {
267
+ const shape = new Map();
268
+ for (const flag of ['scoped', 'version', 'submittable', 'customFields'])
269
+ if (resource[flag])
270
+ shape.set(flag, flag);
271
+ for (const [key, field] of Object.entries(resource.fields)) {
272
+ if (field.type === 'hasMany')
273
+ continue;
274
+ const column = field.column ?? columnName(key);
275
+ const traits = [
276
+ field.type === 'belongsTo' ? `belongsTo ${field.resource}` : field.type,
277
+ ...(field.required || field.sequence ? ['required'] : []),
278
+ ...(field.unique ? ['unique'] : []),
279
+ ...(field.searchable ? ['searchable'] : []),
280
+ ];
281
+ shape.set(column, `${column} (${traits.join(', ')})`);
282
+ }
283
+ return shape;
284
+ }
285
+ /**
286
+ * A generated create-migration embeds the definition as it was at scaffold time. While it is
287
+ * still pending, compare that snapshot with the current resource so the table matches (#20).
288
+ */
289
+ export function diagnoseResourceSnapshots(pending, resource) {
290
+ const drift = [];
291
+ for (const { file, source } of pending) {
292
+ const embedded = /createResourceTable\([^,]+,\s*(\{[\s\S]*\})\s*\)\s*\}/.exec(source)?.[1];
293
+ if (!embedded)
294
+ continue;
295
+ let snapshot;
296
+ try {
297
+ snapshot = JSON.parse(embedded);
298
+ }
299
+ catch {
300
+ continue;
301
+ }
302
+ const current = resource(snapshot.name);
303
+ if (!current)
304
+ continue;
305
+ const [before, after] = [tableShape(snapshot), tableShape(current)];
306
+ const changed = [...new Set([...before.keys(), ...after.keys()])]
307
+ .filter((key) => before.get(key) !== after.get(key))
308
+ .map((key) => after.get(key) ?? `without ${before.get(key)}`);
309
+ if (changed.length)
310
+ drift.push(`${file} (${snapshot.name}): ${changed.join('; ')}`);
311
+ }
312
+ return {
313
+ check: 'resources.snapshots',
314
+ status: drift.length ? 'warn' : 'pass',
315
+ message: drift.length
316
+ ? `Pending create-migrations differ from their resource definitions; run node ace adula:resource:snapshot <name> (or update the embedded definition) before migration:run: ${drift.join(' | ')}`
317
+ : 'Pending resource migrations match their definitions',
318
+ };
319
+ }
320
+ /**
321
+ * Workflow recipients address roles by their stable key, or for compatibility by
322
+ * their editable display name. List references that match no role, and those that
323
+ * only match a display name, which a rename in the roles screen would break (#25).
324
+ */
325
+ export function diagnoseWorkflowRoles(workflows, roles) {
326
+ const keys = new Set(roles.flatMap((role) => (typeof role === 'string' || !role.key ? [] : [role.key])));
327
+ const names = new Set(roles.map((role) => (typeof role === 'string' ? role : role.name)));
328
+ const missing = [];
329
+ const byName = [];
330
+ for (const workflow of workflows)
331
+ for (const [key, step] of Object.entries(workflow.steps)) {
332
+ const to = step.type === 'approval' ? step.assignees : step.type === 'notify' ? step.to : null;
333
+ if (!to || typeof to !== 'object' || !('role' in to) || keys.has(to.role))
334
+ continue;
335
+ const reference = `${workflow.name}@${workflow.version}.${key} → "${to.role}"`;
336
+ if (names.has(to.role))
337
+ byName.push(reference);
338
+ else
339
+ missing.push(reference);
340
+ }
341
+ // Plain name lists (older hosts) cannot tell keys from names; only missing roles matter.
342
+ const keyed = roles.some((role) => typeof role !== 'string');
343
+ const messages = [
344
+ ...(missing.length
345
+ ? [
346
+ `Workflow steps address roles that match no role key or name; approvals there fail: ${missing.join(', ')}`,
347
+ ]
348
+ : []),
349
+ ...(keyed && byName.length
350
+ ? [
351
+ `Workflow steps address roles by display name; give each role a key and address it by key so renaming cannot detach approvers: ${byName.join(', ')}`,
352
+ ]
353
+ : []),
354
+ ];
355
+ return {
356
+ check: 'workflows.roles',
357
+ status: messages.length ? 'warn' : 'pass',
358
+ message: messages.length
359
+ ? messages.join(' | ')
360
+ : 'Every role addressed by a workflow step exists',
361
+ };
362
+ }
363
+ /** Events wait in the outbox until a worker publishes them; a stale backlog means none runs (#50). */
364
+ export function diagnoseOutbox(health) {
365
+ const { backlog, oldestAgeMs } = health.outbox;
366
+ const worker = health.heartbeats.worker.healthy ? 'running' : 'not running (no recent heartbeat)';
367
+ const stale = backlog > 0 && oldestAgeMs !== null && oldestAgeMs >= OUTBOX_STALE_MS;
368
+ return {
369
+ check: 'events.outbox',
370
+ status: stale ? 'warn' : 'pass',
371
+ message: stale
372
+ ? `${backlog} unpublished events, the oldest ${Math.round(oldestAgeMs / 1000)} s old; the worker is ${worker}. Listeners run only while \`node ace adula:worker\` runs`
373
+ : `No outbox event is older than ${OUTBOX_STALE_MS / 1000} s; the worker is ${worker}`,
374
+ };
375
+ }
@@ -1,8 +1,56 @@
1
+ export declare const KIT_ISSUES_URL = "https://github.com/adulash/adula-kit/issues";
2
+ /**
3
+ * KIT_GAPS.md entry fields, in the order of the kit's gap issue form
4
+ * (.github/ISSUE_TEMPLATE/gap.yml). `id` is the form field id used to prefill it.
5
+ */
6
+ export declare const GAP_FIELDS: readonly [{
7
+ readonly label: "Package";
8
+ readonly id: "package";
9
+ readonly required: true;
10
+ }, {
11
+ readonly label: "Needed by";
12
+ readonly id: "needed";
13
+ readonly required: true;
14
+ }, {
15
+ readonly label: "Tried";
16
+ readonly id: "tried";
17
+ readonly required: true;
18
+ }, {
19
+ readonly label: "Blocked because";
20
+ readonly id: "blocked";
21
+ readonly required: true;
22
+ }, {
23
+ readonly label: "Proposed kit change";
24
+ readonly id: "proposal";
25
+ readonly required: true;
26
+ }, {
27
+ readonly label: "Reproduction";
28
+ readonly id: "reproduction";
29
+ readonly required: true;
30
+ }, {
31
+ readonly label: "Acceptance";
32
+ readonly id: "acceptance";
33
+ readonly required: true;
34
+ }, {
35
+ readonly label: "Workaround";
36
+ readonly id: "workaround";
37
+ readonly required: false;
38
+ }];
39
+ export declare const GAPS_TEMPLATE = "# Kit gaps\n\nRecord each limitation of the kit as one entry. Describe the kit capability, not this\nproject: reproduce it on a new application from create-app. Run `node ace adula:gaps report`\nto open the kit's gap form prefilled, then write the issue number in `Issue:`.\n\n<!--\n## GAP-001 \u2014 Short title naming the kit capability\nPackage: @adula/kit 1.0.0\nNeeded by: the kit capability that is missing, stated without project details\nTried: the kit extension points tried\nBlocked because: why none of them works\nProposed kit change: the API, option or fix the kit should offer\nReproduction: steps on a new application from create-app\nAcceptance: the test that proves the gap is closed\nWorkaround: none, or what the project does meanwhile\nIssue:\n-->\n";
40
+ export interface GapEntry {
41
+ title: string;
42
+ fields: Record<string, string>;
43
+ issue: string;
44
+ missing: string[];
45
+ url?: string;
46
+ }
1
47
  /**
2
48
  * Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
3
- * IPv4 addresses are masked, and gap titles are listed for a quick review.
49
+ * IPv4 addresses are masked, gap titles are listed for a quick review, and each
50
+ * unreported gap gets a link that opens the kit's gap form prefilled. Nothing is sent.
4
51
  */
5
- export declare function gapReport(content: string): {
52
+ export declare function gapReport(content: string, issuesUrl?: string): {
6
53
  masked: string;
7
54
  titles: string[];
55
+ gaps: GapEntry[];
8
56
  };
@@ -1,12 +1,110 @@
1
+ export const KIT_ISSUES_URL = 'https://github.com/adulash/adula-kit/issues';
2
+ /**
3
+ * KIT_GAPS.md entry fields, in the order of the kit's gap issue form
4
+ * (.github/ISSUE_TEMPLATE/gap.yml). `id` is the form field id used to prefill it.
5
+ */
6
+ export const GAP_FIELDS = [
7
+ { label: 'Package', id: 'package', required: true },
8
+ { label: 'Needed by', id: 'needed', required: true },
9
+ { label: 'Tried', id: 'tried', required: true },
10
+ { label: 'Blocked because', id: 'blocked', required: true },
11
+ { label: 'Proposed kit change', id: 'proposal', required: true },
12
+ { label: 'Reproduction', id: 'reproduction', required: true },
13
+ { label: 'Acceptance', id: 'acceptance', required: true },
14
+ { label: 'Workaround', id: 'workaround', required: false },
15
+ ];
16
+ export const GAPS_TEMPLATE = `# Kit gaps
17
+
18
+ Record each limitation of the kit as one entry. Describe the kit capability, not this
19
+ project: reproduce it on a new application from create-app. Run \`node ace adula:gaps report\`
20
+ to open the kit's gap form prefilled, then write the issue number in \`Issue:\`.
21
+
22
+ <!--
23
+ ## GAP-001 — Short title naming the kit capability
24
+ Package: @adula/kit 1.0.0
25
+ Needed by: the kit capability that is missing, stated without project details
26
+ Tried: the kit extension points tried
27
+ Blocked because: why none of them works
28
+ Proposed kit change: the API, option or fix the kit should offer
29
+ Reproduction: steps on a new application from create-app
30
+ Acceptance: the test that proves the gap is closed
31
+ Workaround: none, or what the project does meanwhile
32
+ Issue:
33
+ -->
34
+ `;
35
+ /** Longest value put in one prefilled field; GitHub rejects very long URLs. */
36
+ const FIELD_LIMIT = 1500;
37
+ const labels = [...GAP_FIELDS.map((field) => field.label), 'Issue'];
38
+ const fieldLine = new RegExp(`^(${labels.join('|')}):\\s*(.*)$`);
39
+ function parseGaps(masked) {
40
+ const gaps = [];
41
+ let current;
42
+ let field;
43
+ let comment = false;
44
+ for (const line of masked.split(/\r?\n/)) {
45
+ if (line.trim().startsWith('<!--'))
46
+ comment = true;
47
+ if (comment) {
48
+ if (line.includes('-->'))
49
+ comment = false;
50
+ continue;
51
+ }
52
+ const heading = line.match(/^## (GAP-\d+.*)$/);
53
+ if (heading) {
54
+ current = { title: heading[1].trim(), fields: {}, issue: '', missing: [] };
55
+ gaps.push(current);
56
+ field = undefined;
57
+ continue;
58
+ }
59
+ if (!current)
60
+ continue;
61
+ if (line.startsWith('#')) {
62
+ current = undefined;
63
+ continue;
64
+ }
65
+ const match = line.match(fieldLine);
66
+ if (match) {
67
+ field = match[1];
68
+ if (field === 'Issue')
69
+ current.issue = match[2].trim();
70
+ else
71
+ current.fields[field] = match[2].trim();
72
+ }
73
+ else if (field && field !== 'Issue') {
74
+ current.fields[field] = `${current.fields[field]}\n${line}`.trim();
75
+ }
76
+ }
77
+ return gaps;
78
+ }
79
+ function clip(value) {
80
+ return value.length > FIELD_LIMIT
81
+ ? `${value.slice(0, FIELD_LIMIT)}\n… (shortened; copy the rest from the masked report)`
82
+ : value;
83
+ }
1
84
  /**
2
85
  * Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
3
- * IPv4 addresses are masked, and gap titles are listed for a quick review.
86
+ * IPv4 addresses are masked, gap titles are listed for a quick review, and each
87
+ * unreported gap gets a link that opens the kit's gap form prefilled. Nothing is sent.
4
88
  */
5
- export function gapReport(content) {
89
+ export function gapReport(content, issuesUrl = KIT_ISSUES_URL) {
6
90
  const masked = content
7
91
  .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>')
8
92
  .replace(/https?:\/\/[^\s)]+/g, '<url>')
9
93
  .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<ip>');
10
- const titles = (masked.match(/^## GAP-\d+.*$/gm) ?? []).map((title) => title.replace(/^## /, ''));
11
- return { masked, titles };
94
+ const gaps = parseGaps(masked);
95
+ for (const gap of gaps) {
96
+ gap.missing = GAP_FIELDS.filter((field) => field.required && !gap.fields[field.label]).map((field) => field.label);
97
+ if (gap.issue)
98
+ continue;
99
+ const url = new URL(`${issuesUrl}/new`);
100
+ url.searchParams.set('template', 'gap.yml');
101
+ url.searchParams.set('title', gap.title.replace(/^GAP-\d+\s*[—–-]?\s*/, ''));
102
+ for (const field of GAP_FIELDS) {
103
+ const value = gap.fields[field.label];
104
+ if (value)
105
+ url.searchParams.set(field.id, clip(value));
106
+ }
107
+ gap.url = url.toString();
108
+ }
109
+ return { masked, titles: gaps.map((gap) => gap.title), gaps };
12
110
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, access, glob } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { identifier } from '../resource/define_resource.js';
4
- import { appendMarkedItem } from './source_markers.js';
4
+ import { appendMarkedItem, moduleSource } from './source_markers.js';
5
5
  export async function generateResource(root, name, module) {
6
6
  identifier(name);
7
7
  identifier(module);
@@ -10,7 +10,7 @@ export async function generateResource(root, name, module) {
10
10
  const base = resolve(root, 'app/modules', module);
11
11
  const title = name.replace(/_/g, ' ');
12
12
  const relative = `app/modules/${module}`;
13
- const resourceSource = `import { defineResource } from '@adula/kit'\nimport Model from '#modules/${module}/models/${name}'\nimport { validator } from '#modules/${module}/validators/${name}'\n\nexport default defineResource({\n name: '${name}', label: { ar: '${title}', en: '${title}' }, model: Model, scoped: true,\n fields: { title: { type: 'string', label: { ar: 'العنوان', en: 'Title' }, required: true, searchable: true } },\n list: ['title'], form: ['title'], show: ['title'],\n actions: ['view', 'create', 'update', 'delete'], validator,\n})\n`;
13
+ const resourceSource = `import { defineResource } from '@adula/kit'\nimport Model from '#modules/${module}/models/${name}'\nimport { validator } from '#modules/${module}/validators/${name}'\n\nexport default defineResource({\n name: '${name}', label: { ar: '${title}', en: '${title}' },\n recordLabel: { ar: '${title}', en: '${title}' }, model: Model, scoped: true,\n fields: { title: { type: 'string', label: { ar: 'العنوان', en: 'Title' }, required: true, searchable: true } },\n list: ['title'], form: ['title'], show: ['title'],\n actions: ['view', 'create', 'update', 'delete'], validator,\n})\n`;
14
14
  const migration = {
15
15
  name,
16
16
  scoped: true,
@@ -39,7 +39,7 @@ export async function generateResource(root, name, module) {
39
39
  catch (error) {
40
40
  if (error.code !== 'ENOENT')
41
41
  throw error;
42
- current = `// adula:imports\nexport default { name: '${module}', label: { ar: '${module}', en: '${module}' }, dependsOn: [], resources: [/* adula:resources */] }\n`;
42
+ current = moduleSource(module);
43
43
  }
44
44
  if (!current.includes('// adula:imports') || !current.includes('/* adula:resources */'))
45
45
  throw new Error('Module registration markers are missing; no files were changed');
@@ -0,0 +1,28 @@
1
+ import type { Resource } from '../resource/types.js';
2
+ /**
3
+ * The part of a resource definition createResourceTable reads, as a generated
4
+ * create-migration embeds it. Labels are kept so the migration stays readable.
5
+ */
6
+ export declare function resourceSnapshot(resource: Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields' | 'fields'>): {
7
+ fields: {
8
+ [k: string]: {
9
+ sequence?: string | undefined;
10
+ searchable?: boolean | undefined;
11
+ unique?: boolean | undefined;
12
+ required?: boolean | undefined;
13
+ column?: string | undefined;
14
+ resource?: string | undefined;
15
+ type: "string" | "boolean" | "text" | "integer" | "money" | "date" | "datetime" | "json" | "attachment" | "belongsTo" | "user" | "hasMany" | "lookup";
16
+ label: import("../resource/types.js").Label;
17
+ };
18
+ };
19
+ customFields?: boolean | undefined;
20
+ submittable?: boolean | undefined;
21
+ version?: boolean | undefined;
22
+ name: string;
23
+ scoped: boolean;
24
+ };
25
+ /** The resource name embedded in a generated create-migration, if the source is one. */
26
+ export declare function embeddedResource(source: string): string | undefined;
27
+ /** Replaces the embedded definition of a generated create-migration (#20). */
28
+ export declare function rewriteResourceSnapshot(source: string, snapshot: ReturnType<typeof resourceSnapshot>): string;
@@ -0,0 +1,48 @@
1
+ const EMBEDDED = /(createResourceTable\([^,]+,\s*)(\{[\s\S]*\})(\s*\)\s*\})/;
2
+ /**
3
+ * The part of a resource definition createResourceTable reads, as a generated
4
+ * create-migration embeds it. Labels are kept so the migration stays readable.
5
+ */
6
+ export function resourceSnapshot(resource) {
7
+ return {
8
+ name: resource.name,
9
+ scoped: resource.scoped,
10
+ ...(resource.version ? { version: true } : {}),
11
+ ...(resource.submittable ? { submittable: true } : {}),
12
+ ...(resource.customFields ? { customFields: true } : {}),
13
+ fields: Object.fromEntries(Object.entries(resource.fields)
14
+ .filter(([, field]) => field.type !== 'hasMany')
15
+ .map(([key, field]) => [
16
+ key,
17
+ {
18
+ type: field.type,
19
+ label: field.label,
20
+ ...(field.type === 'belongsTo' ? { resource: field.resource } : {}),
21
+ ...(field.column ? { column: field.column } : {}),
22
+ ...(field.required ? { required: true } : {}),
23
+ ...(field.unique ? { unique: true } : {}),
24
+ ...(field.searchable ? { searchable: true } : {}),
25
+ ...(field.sequence ? { sequence: field.sequence } : {}),
26
+ },
27
+ ])),
28
+ };
29
+ }
30
+ /** The resource name embedded in a generated create-migration, if the source is one. */
31
+ export function embeddedResource(source) {
32
+ const embedded = EMBEDDED.exec(source)?.[2];
33
+ if (!embedded)
34
+ return undefined;
35
+ try {
36
+ const parsed = JSON.parse(embedded);
37
+ return typeof parsed?.name === 'string' ? parsed.name : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Replaces the embedded definition of a generated create-migration (#20). */
44
+ export function rewriteResourceSnapshot(source, snapshot) {
45
+ if (embeddedResource(source) !== snapshot.name)
46
+ throw new Error(`The migration does not embed the ${snapshot.name} definition`);
47
+ return source.replace(EMBEDDED, (_, before, _old, after) => `${before}${JSON.stringify(snapshot, null, 2)}${after}`);
48
+ }
@@ -1,2 +1,11 @@
1
- /** Append to a marked literal array, including when the last item has no trailing comma. */
1
+ /**
2
+ * Append to a marked literal array and rewrite it in the multi-line form Prettier keeps stable:
3
+ * one item per line with a trailing comma and the marker last. Single-line arrays written by
4
+ * earlier generators (`[a, /* marker *\/]`, `[a /* marker *\/]`) are normalized the same way.
5
+ */
2
6
  export declare function appendMarkedItem(source: string, marker: string, expression: string): string;
7
+ /** A new module definition, already in the project's Prettier layout. */
8
+ export declare function moduleSource(name: string, options?: {
9
+ reference?: boolean;
10
+ typed?: boolean;
11
+ }): string;
@@ -1,9 +1,41 @@
1
- /** Append to a marked literal array, including when the last item has no trailing comma. */
1
+ /**
2
+ * Append to a marked literal array and rewrite it in the multi-line form Prettier keeps stable:
3
+ * one item per line with a trailing comma and the marker last. Single-line arrays written by
4
+ * earlier generators (`[a, /* marker *\/]`, `[a /* marker *\/]`) are normalized the same way.
5
+ */
2
6
  export function appendMarkedItem(source, marker, expression) {
3
7
  const offset = source.indexOf(marker);
4
8
  if (offset < 0 || source.indexOf(marker, offset + marker.length) >= 0)
5
9
  throw new Error(`Expected one registration marker: ${marker}`);
6
- const before = source.slice(0, offset).trimEnd();
7
- const separator = /[\[,]$/.test(before) ? '' : ', ';
8
- return source.replace(marker, `${separator}${expression}, ${marker}`);
10
+ const open = source.lastIndexOf('[', offset);
11
+ const close = source.indexOf(']', offset + marker.length);
12
+ if (open < 0 || close < 0)
13
+ throw new Error(`Registration marker ${marker} must be inside an array`);
14
+ const listed = source.slice(open + 1, offset);
15
+ if (/[()[\]{}'"`]|\/\/|\/\*/.test(listed) || source.slice(offset + marker.length, close).trim())
16
+ throw new Error(`Registration list around ${marker} is not a plain list; add ${expression} by hand`);
17
+ const items = listed
18
+ .split(',')
19
+ .map((item) => item.trim())
20
+ .filter(Boolean);
21
+ const lineStart = source.lastIndexOf('\n', open) + 1;
22
+ const indent = /^[ \t]*/.exec(source.slice(lineStart, open))[0];
23
+ const inner = `${indent} `;
24
+ const lines = [...items, expression].map((item) => `${inner}${item},\n`).join('');
25
+ return `${source.slice(0, open)}[\n${lines}${inner}${marker}\n${indent}]${source.slice(close + 1)}`;
26
+ }
27
+ /** A new module definition, already in the project's Prettier layout. */
28
+ export function moduleSource(name, options = {}) {
29
+ return [
30
+ ...(options.typed ? ["import type { Module } from '@adula/kit'"] : []),
31
+ '// adula:imports',
32
+ 'export default {',
33
+ ` name: '${name}',`,
34
+ ` label: { ar: '${name}', en: '${name}' },`,
35
+ ...(options.reference === undefined ? [] : [` reference: ${options.reference},`]),
36
+ ' dependsOn: [],',
37
+ ' resources: [/* adula:resources */],',
38
+ `}${options.typed ? ' satisfies Module' : ''}`,
39
+ '',
40
+ ].join('\n');
9
41
  }
@@ -1,4 +1,5 @@
1
1
  import { buildAbility } from '../auth/ability.js';
2
+ import { resolveActorConditions } from '../auth/conditions.js';
2
3
  import { KitError } from '../admin/errors.js';
3
4
  /** Same CASL decision as the admin middleware, with fresh rows inside the transaction. */
4
5
  async function administrators(db) {
@@ -16,7 +17,8 @@ async function administrators(db) {
16
17
  subject: row.subject,
17
18
  action: row.action,
18
19
  inverted: row.inverted,
19
- conditions: row.conditions ?? undefined,
20
+ // Only subject 'all' is read here, so no field accepts the placeholder.
21
+ conditions: resolveActorConditions(row.conditions ?? undefined, Number(row.user_id), () => false),
20
22
  fields: row.fields ?? undefined,
21
23
  });
22
24
  users.set(row.user_id, rules);
@@ -0,0 +1,83 @@
1
+ import type { Knex } from 'knex';
2
+ import { type NotificationTarget } from '../services/settings.js';
3
+ export type TemplateDefinition = {
4
+ label: string;
5
+ subject: string;
6
+ body: string;
7
+ /** Variables available as {{name}}; unknown placeholders are refused on edit. */
8
+ variables: string[];
9
+ /** Whether notifications from this template are also delivered by e-mail. */
10
+ mail: boolean;
11
+ };
12
+ export type MessageTemplate = TemplateDefinition & {
13
+ key: string;
14
+ customized: boolean;
15
+ updatedAt: string | null;
16
+ };
17
+ export type RenderedMessage = {
18
+ subject: string;
19
+ body: string;
20
+ mail: boolean;
21
+ };
22
+ /**
23
+ * Package-owned defaults. Projects edit wording in the message_templates table;
24
+ * an upgrade can change a default without overwriting a project's edit.
25
+ */
26
+ export declare const DEFAULT_TEMPLATES: Record<string, TemplateDefinition>;
27
+ export declare function renderTemplate(text: string, variables: Record<string, unknown>): string;
28
+ /** Message templates: package defaults plus per-deployment overrides edited by administrators. */
29
+ export declare class MessageTemplates {
30
+ private db;
31
+ private definitions;
32
+ constructor(db: Knex, definitions?: Record<string, TemplateDefinition>);
33
+ list(): Promise<MessageTemplate[]>;
34
+ render(key: string, variables: Record<string, unknown>, db?: Knex): Promise<RenderedMessage>;
35
+ update(key: string, input: {
36
+ subject: unknown;
37
+ body: unknown;
38
+ mail?: unknown;
39
+ }, actorId: number): Promise<void>;
40
+ reset(key: string): Promise<void>;
41
+ /** Example output with sample values, for the editor preview. */
42
+ preview(key: string, input: {
43
+ subject: string;
44
+ body: string;
45
+ }): {
46
+ subject: string;
47
+ body: string;
48
+ };
49
+ private definition;
50
+ }
51
+ /**
52
+ * Inserts a templated notification in the caller's transaction. Templates with
53
+ * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
54
+ */
55
+ export declare function notifyWithTemplate(db: Knex, userId: number, key: string, variables: Record<string, unknown>, templates?: MessageTemplates, target?: NotificationTarget | null): Promise<void>;
56
+ export type MailSender = (message: {
57
+ to: string;
58
+ name: string | null;
59
+ subject: string;
60
+ text: string;
61
+ /** The record the notification is about; build an absolute link from the application URL. */
62
+ target?: NotificationTarget | null;
63
+ }) => Promise<void>;
64
+ /**
65
+ * Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
66
+ * several workers never send the same message; failures are retried three times.
67
+ */
68
+ export declare function deliverNotificationMail(db: Knex, send: MailSender, limit?: number): Promise<{
69
+ sent: number;
70
+ failed: number;
71
+ }>;
72
+ export type NotificationSignal = {
73
+ userId: number;
74
+ id: number;
75
+ };
76
+ /**
77
+ * Holds one database connection that LISTENs for committed notifications and
78
+ * calls onSignal for each. Reconnects after connection loss. Returns a stop function.
79
+ */
80
+ export declare function listenForNotifications(db: Knex, onSignal: (signal: NotificationSignal) => void, options?: {
81
+ retryMs?: number;
82
+ onError?: (error: unknown) => void;
83
+ }): () => Promise<void>;