@adula/kit 0.2.0-alpha.4 → 1.0.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 (63) hide show
  1. package/README.md +12 -2
  2. package/build/agent/capabilities.md +49 -7
  3. package/build/agent/skills/idea-review/SKILL.md +26 -1
  4. package/build/agent/skills/module-review/SKILL.md +22 -0
  5. package/build/agent/skills/perf-review/SKILL.md +24 -0
  6. package/build/agent/skills/schema-review/SKILL.md +22 -0
  7. package/build/agent/skills/security-review/SKILL.md +24 -0
  8. package/build/agent/skills/ui-review/SKILL.md +22 -0
  9. package/build/commands/capabilities.d.ts +4 -0
  10. package/build/commands/capabilities.js +35 -4
  11. package/build/commands/main.d.ts +4 -2
  12. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  13. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  14. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  15. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  16. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  17. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  18. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  19. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  20. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  21. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  22. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  23. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  24. package/build/index.d.ts +21 -1
  25. package/build/index.js +12 -1
  26. package/build/src/admin/contracts.js +19 -10
  27. package/build/src/admin/controller.d.ts +1 -0
  28. package/build/src/admin/controller.js +6 -0
  29. package/build/src/admin/resource_service.d.ts +21 -0
  30. package/build/src/admin/resource_service.js +163 -7
  31. package/build/src/collaboration/assignments.d.ts +78 -0
  32. package/build/src/collaboration/assignments.js +219 -0
  33. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  34. package/build/src/collaboration/record_collaboration.js +360 -0
  35. package/build/src/commands/agent_assets.js +5 -0
  36. package/build/src/commands/capabilities.d.ts +19 -0
  37. package/build/src/commands/capabilities.js +160 -0
  38. package/build/src/core/message_templates.d.ts +80 -0
  39. package/build/src/core/message_templates.js +288 -0
  40. package/build/src/database/schema.d.ts +18 -0
  41. package/build/src/database/schema.js +192 -0
  42. package/build/src/events/outbox.d.ts +1 -0
  43. package/build/src/events/outbox.js +1 -1
  44. package/build/src/events/record_mutation.d.ts +7 -0
  45. package/build/src/events/record_mutation.js +13 -2
  46. package/build/src/integrations/imports.d.ts +74 -0
  47. package/build/src/integrations/imports.js +331 -0
  48. package/build/src/integrations/openapi.d.ts +39 -0
  49. package/build/src/integrations/openapi.js +256 -0
  50. package/build/src/integrations/print.d.ts +37 -0
  51. package/build/src/integrations/print.js +123 -0
  52. package/build/src/integrations/webhooks.d.ts +98 -0
  53. package/build/src/integrations/webhooks.js +298 -0
  54. package/build/src/resource/define_resource.d.ts +1 -0
  55. package/build/src/resource/define_resource.js +9 -1
  56. package/build/src/resource/registry.d.ts +2 -0
  57. package/build/src/resource/registry.js +9 -0
  58. package/build/src/resource/types.d.ts +3 -0
  59. package/build/src/workflows/define_workflow.d.ts +95 -0
  60. package/build/src/workflows/define_workflow.js +83 -0
  61. package/build/src/workflows/engine.d.ts +119 -0
  62. package/build/src/workflows/engine.js +687 -0
  63. package/package.json +7 -3
@@ -0,0 +1,288 @@
1
+ import { KitError } from '../admin/errors.js';
2
+ /**
3
+ * Package-owned defaults. Projects edit wording in the message_templates table;
4
+ * an upgrade can change a default without overwriting a project's edit.
5
+ */
6
+ export const DEFAULT_TEMPLATES = {
7
+ 'comment.mentioned': {
8
+ label: 'إشارة في تعليق',
9
+ subject: 'أشار إليك {{author}}',
10
+ body: 'في {{resource}} #{{id}}: {{excerpt}}',
11
+ variables: ['author', 'resource', 'id', 'excerpt'],
12
+ mail: true,
13
+ },
14
+ 'comment.created': {
15
+ label: 'تعليق على سجل تتابعه',
16
+ subject: 'تعليق جديد على {{resource}} #{{id}}',
17
+ body: '{{author}}: {{excerpt}}',
18
+ variables: ['author', 'resource', 'id', 'excerpt'],
19
+ mail: false,
20
+ },
21
+ 'record.changed': {
22
+ label: 'تغيير سجل تتابعه',
23
+ subject: '{{change}} {{resource}} #{{id}}',
24
+ body: 'سجل تتابعه تغيّر.',
25
+ variables: ['change', 'resource', 'id'],
26
+ mail: false,
27
+ },
28
+ 'assignment.created': {
29
+ label: 'مهمة جديدة',
30
+ subject: 'مهمة جديدة مسندة إليك',
31
+ body: '{{title}} — {{resource}} #{{id}}',
32
+ variables: ['title', 'resource', 'id', 'due'],
33
+ mail: true,
34
+ },
35
+ 'assignment.approval': {
36
+ label: 'طلب موافقة',
37
+ subject: 'موافقة مطلوبة منك',
38
+ body: '{{title}} — {{resource}} #{{id}}',
39
+ variables: ['title', 'resource', 'id', 'due'],
40
+ mail: true,
41
+ },
42
+ 'assignment.done': {
43
+ label: 'إنجاز مهمة أسندتها',
44
+ subject: 'أُنجزت مهمة أسندتها',
45
+ body: '{{title}} — {{resource}} #{{id}}',
46
+ variables: ['title', 'resource', 'id'],
47
+ mail: false,
48
+ },
49
+ 'assignment.cancelled': {
50
+ label: 'إلغاء مهمة مسندة إليك',
51
+ subject: 'أُلغيت مهمة مسندة إليك',
52
+ body: '{{title}} — {{resource}} #{{id}}',
53
+ variables: ['title', 'resource', 'id'],
54
+ mail: false,
55
+ },
56
+ 'workflow.decided': {
57
+ label: 'نتيجة تدفق عمل',
58
+ subject: '{{outcome}}: {{resource}} #{{id}}',
59
+ body: '{{workflow}} — {{step}}',
60
+ variables: ['outcome', 'resource', 'id', 'workflow', 'step'],
61
+ mail: true,
62
+ },
63
+ 'workflow.failed': {
64
+ label: 'فشل خطوة تدفق',
65
+ subject: 'فشلت خطوة في تدفق {{workflow}}',
66
+ body: '{{resource}} #{{id}} — {{error}}',
67
+ variables: ['workflow', 'resource', 'id', 'error'],
68
+ mail: true,
69
+ },
70
+ 'webhook.failed': {
71
+ label: 'تعطل Webhook',
72
+ subject: 'تعذّر تسليم Webhook: {{name}}',
73
+ body: '{{event}} — {{error}}',
74
+ variables: ['name', 'event', 'error'],
75
+ mail: true,
76
+ },
77
+ 'import.finished': {
78
+ label: 'اكتمال استيراد',
79
+ subject: 'اكتمل استيراد {{resource}}',
80
+ body: 'نجح {{created}} صفاً، وفشل {{failed}}.',
81
+ variables: ['resource', 'created', 'failed'],
82
+ mail: false,
83
+ },
84
+ };
85
+ const PLACEHOLDER = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
86
+ const SUBJECT_LIMIT = 200;
87
+ const BODY_LIMIT = 4000;
88
+ export function renderTemplate(text, variables) {
89
+ return text.replace(PLACEHOLDER, (_, name) => {
90
+ const value = variables[name];
91
+ return value === null || value === undefined ? '' : String(value);
92
+ });
93
+ }
94
+ /** Message templates: package defaults plus per-deployment overrides edited by administrators. */
95
+ export class MessageTemplates {
96
+ db;
97
+ definitions;
98
+ constructor(db, definitions = DEFAULT_TEMPLATES) {
99
+ this.db = db;
100
+ this.definitions = definitions;
101
+ }
102
+ async list() {
103
+ const rows = await this.db('message_templates').select('*');
104
+ const overrides = new Map(rows.map((row) => [String(row.key), row]));
105
+ return Object.entries(this.definitions).map(([key, definition]) => {
106
+ const row = overrides.get(key);
107
+ return {
108
+ key,
109
+ ...definition,
110
+ subject: row ? String(row.subject) : definition.subject,
111
+ body: row ? String(row.body) : definition.body,
112
+ mail: row ? Boolean(row.mail) : definition.mail,
113
+ customized: Boolean(row),
114
+ updatedAt: row ? new Date(row.updated_at).toISOString() : null,
115
+ };
116
+ });
117
+ }
118
+ async render(key, variables, db = this.db) {
119
+ const definition = this.definition(key);
120
+ const row = await db('message_templates').where('key', key).first();
121
+ return {
122
+ subject: renderTemplate(row ? String(row.subject) : definition.subject, variables).slice(0, 255),
123
+ body: renderTemplate(row ? String(row.body) : definition.body, variables),
124
+ mail: row ? Boolean(row.mail) : definition.mail,
125
+ };
126
+ }
127
+ async update(key, input, actorId) {
128
+ const definition = this.definition(key);
129
+ const subject = typeof input.subject === 'string' ? input.subject.trim() : '';
130
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
131
+ if (!subject || subject.length > SUBJECT_LIMIT)
132
+ throw new KitError(422, 'E_TEMPLATE_SUBJECT', 'العنوان مطلوب ولا يتجاوز 200 حرف');
133
+ if (!body || body.length > BODY_LIMIT)
134
+ throw new KitError(422, 'E_TEMPLATE_BODY', 'نص الرسالة مطلوب ولا يتجاوز 4000 حرف');
135
+ for (const text of [subject, body])
136
+ for (const [, name] of text.matchAll(PLACEHOLDER))
137
+ if (!definition.variables.includes(name))
138
+ throw new KitError(422, 'E_TEMPLATE_VARIABLE', `متغير غير معروف في القالب: ${name}`);
139
+ const mail = input.mail === undefined ? definition.mail : input.mail === true;
140
+ await this.db('message_templates')
141
+ .insert({ key, subject, body, mail, updated_by: actorId, updated_at: this.db.fn.now() })
142
+ .onConflict('key')
143
+ .merge(['subject', 'body', 'mail', 'updated_by', 'updated_at']);
144
+ }
145
+ async reset(key) {
146
+ this.definition(key);
147
+ await this.db('message_templates').where('key', key).del();
148
+ }
149
+ /** Example output with sample values, for the editor preview. */
150
+ preview(key, input) {
151
+ const definition = this.definition(key);
152
+ const sample = Object.fromEntries(definition.variables.map((name) => [name, `‹${name}›`]));
153
+ return {
154
+ subject: renderTemplate(input.subject, sample),
155
+ body: renderTemplate(input.body, sample),
156
+ };
157
+ }
158
+ definition(key) {
159
+ const definition = Object.hasOwn(this.definitions, key) ? this.definitions[key] : undefined;
160
+ if (!definition)
161
+ throw new KitError(404, 'E_TEMPLATE_NOT_FOUND', 'القالب غير موجود');
162
+ return definition;
163
+ }
164
+ }
165
+ /**
166
+ * Inserts a templated notification in the caller's transaction. Templates with
167
+ * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
168
+ */
169
+ export async function notifyWithTemplate(db, userId, key, variables, templates = new MessageTemplates(db)) {
170
+ const message = await templates.render(key, variables, db);
171
+ await db('notifications').insert({
172
+ user_id: userId,
173
+ title: message.subject,
174
+ body: message.body,
175
+ template_key: key,
176
+ mail_state: message.mail ? 'pending' : null,
177
+ });
178
+ }
179
+ /**
180
+ * Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
181
+ * several workers never send the same message; failures are retried three times.
182
+ */
183
+ export async function deliverNotificationMail(db, send, limit = 50) {
184
+ let sent = 0;
185
+ let failed = 0;
186
+ await db.transaction(async (trx) => {
187
+ const rows = await trx('notifications as n')
188
+ .join('users as u', 'u.id', 'n.user_id')
189
+ .where('n.mail_state', 'pending')
190
+ .orderBy('n.id')
191
+ .limit(limit)
192
+ .forUpdate('n')
193
+ .skipLocked()
194
+ .select('n.id', 'n.title', 'n.body', 'n.mail_attempts', 'u.email', 'u.full_name', 'u.disabled_at');
195
+ for (const row of rows) {
196
+ if (row.disabled_at) {
197
+ await trx('notifications').where('id', row.id).update({ mail_state: 'skipped' });
198
+ continue;
199
+ }
200
+ try {
201
+ await send({
202
+ to: String(row.email),
203
+ name: row.full_name ? String(row.full_name) : null,
204
+ subject: String(row.title),
205
+ text: String(row.body),
206
+ });
207
+ await trx('notifications')
208
+ .where('id', row.id)
209
+ .update({ mail_state: 'sent', mailed_at: trx.fn.now() });
210
+ sent++;
211
+ }
212
+ catch (error) {
213
+ const attempts = Number(row.mail_attempts ?? 0) + 1;
214
+ await trx('notifications')
215
+ .where('id', row.id)
216
+ .update({
217
+ mail_state: attempts >= 3 ? 'failed' : 'pending',
218
+ mail_attempts: attempts,
219
+ mail_error: String(error.message ?? error).slice(0, 500),
220
+ });
221
+ failed++;
222
+ }
223
+ }
224
+ });
225
+ return { sent, failed };
226
+ }
227
+ /**
228
+ * Holds one database connection that LISTENs for committed notifications and
229
+ * calls onSignal for each. Reconnects after connection loss. Returns a stop function.
230
+ */
231
+ export function listenForNotifications(db, onSignal, options = {}) {
232
+ let stopped = false;
233
+ let connection;
234
+ let timer;
235
+ const connect = async () => {
236
+ try {
237
+ connection = await db.client.acquireConnection();
238
+ connection.on('notification', (message) => {
239
+ if (message.channel !== 'kit_notifications' || !message.payload)
240
+ return;
241
+ try {
242
+ const parsed = JSON.parse(message.payload);
243
+ if (Number.isSafeInteger(parsed.userId) && Number.isSafeInteger(parsed.id))
244
+ onSignal({ userId: parsed.userId, id: parsed.id });
245
+ }
246
+ catch (error) {
247
+ options.onError?.(error);
248
+ }
249
+ });
250
+ connection.once('error', (error) => {
251
+ options.onError?.(error);
252
+ void reconnect();
253
+ });
254
+ await connection.query('LISTEN kit_notifications');
255
+ }
256
+ catch (error) {
257
+ options.onError?.(error);
258
+ void reconnect();
259
+ }
260
+ };
261
+ const release = async () => {
262
+ const current = connection;
263
+ connection = undefined;
264
+ if (!current)
265
+ return;
266
+ current.removeAllListeners('notification');
267
+ try {
268
+ await current.query('UNLISTEN *');
269
+ }
270
+ catch { }
271
+ // A broken connection is destroyed rather than returned to the pool.
272
+ await db.client.releaseConnection(current).catch(() => { });
273
+ };
274
+ const reconnect = async () => {
275
+ await release();
276
+ if (stopped)
277
+ return;
278
+ timer = setTimeout(() => void connect(), options.retryMs ?? 2000);
279
+ timer.unref?.();
280
+ };
281
+ void connect();
282
+ return async () => {
283
+ stopped = true;
284
+ if (timer)
285
+ clearTimeout(timer);
286
+ await release();
287
+ };
288
+ }
@@ -5,3 +5,21 @@ export declare function createCoreSchema(db: Knex): Promise<void>;
5
5
  export declare function createAttachmentsSchema(db: Knex): Promise<void>;
6
6
  export declare function createSavedViewsSchema(db: Knex): Promise<void>;
7
7
  export declare function createResourceTable(db: Knex, resource: Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields' | 'fields'>): Promise<void>;
8
+ /** Record collaboration (phase 3): comments with mentions, followers, tags and field history. */
9
+ export declare function createCollaborationSchema(db: Knex): Promise<void>;
10
+ /** Work assigned to a user on a record; approval steps of workflows reuse it (phase 4). */
11
+ export declare function createAssignmentsSchema(db: Knex): Promise<void>;
12
+ /**
13
+ * Message templates, notification e-mail delivery state and the realtime signal.
14
+ * The trigger's NOTIFY is delivered only when the inserting transaction commits.
15
+ */
16
+ export declare function createMessagingSchema(db: Knex): Promise<void>;
17
+ /** Outgoing webhooks and their delivery log (exactly one row per webhook and event). */
18
+ export declare function createWebhooksSchema(db: Knex): Promise<void>;
19
+ /** CSV import batches: parsed rows, column mapping, progress and per-row errors. */
20
+ export declare function createImportsSchema(db: Knex): Promise<void>;
21
+ /**
22
+ * Workflow engine state (phase 4): the submission envelope rows gain execution
23
+ * columns, and every transition is appended to workflow_events.
24
+ */
25
+ export declare function createWorkflowSchema(db: Knex): Promise<void>;
@@ -246,3 +246,195 @@ export async function createResourceTable(db, resource) {
246
246
  await db.raw('CREATE INDEX ?? ON ?? USING gin(search_vector)', [`${table}_search_gin`, table]);
247
247
  }
248
248
  }
249
+ /** Record collaboration (phase 3): comments with mentions, followers, tags and field history. */
250
+ export async function createCollaborationSchema(db) {
251
+ await db.schema.createTable('comments', (t) => {
252
+ t.bigIncrements('id');
253
+ t.string('resource').notNullable();
254
+ t.integer('record_id').notNullable();
255
+ t.integer('author_id').notNullable().references('id').inTable('users').onDelete('RESTRICT');
256
+ t.text('body').notNullable();
257
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
258
+ t.timestamp('edited_at', { useTz: true });
259
+ t.timestamp('deleted_at', { useTz: true });
260
+ t.index(['resource', 'record_id', 'id']);
261
+ });
262
+ await db.schema.createTable('comment_mentions', (t) => {
263
+ t.bigInteger('comment_id')
264
+ .notNullable()
265
+ .references('id')
266
+ .inTable('comments')
267
+ .onDelete('CASCADE');
268
+ t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
269
+ t.primary(['comment_id', 'user_id']);
270
+ t.index(['user_id']);
271
+ });
272
+ await db.schema.createTable('followers', (t) => {
273
+ t.string('resource').notNullable();
274
+ t.integer('record_id').notNullable();
275
+ t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
276
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
277
+ t.primary(['resource', 'record_id', 'user_id']);
278
+ t.index(['user_id']);
279
+ });
280
+ await db.schema.createTable('tags', (t) => {
281
+ t.increments('id');
282
+ t.string('name', 60).notNullable().unique();
283
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
284
+ });
285
+ await db.schema.createTable('taggables', (t) => {
286
+ t.integer('tag_id').notNullable().references('id').inTable('tags').onDelete('CASCADE');
287
+ t.string('resource').notNullable();
288
+ t.integer('record_id').notNullable();
289
+ t.primary(['tag_id', 'resource', 'record_id']);
290
+ t.index(['resource', 'record_id']);
291
+ });
292
+ await db.schema.createTable('field_changes', (t) => {
293
+ t.bigIncrements('id');
294
+ t.bigInteger('activity_id')
295
+ .notNullable()
296
+ .references('id')
297
+ .inTable('activities')
298
+ .onDelete('CASCADE')
299
+ .index();
300
+ t.string('resource').notNullable();
301
+ t.integer('record_id').notNullable();
302
+ t.string('field').notNullable();
303
+ t.jsonb('before');
304
+ t.jsonb('after');
305
+ t.index(['resource', 'record_id', 'id']);
306
+ });
307
+ }
308
+ /** Work assigned to a user on a record; approval steps of workflows reuse it (phase 4). */
309
+ export async function createAssignmentsSchema(db) {
310
+ await db.schema.createTable('assignments', (t) => {
311
+ t.bigIncrements('id');
312
+ t.string('resource').notNullable();
313
+ t.integer('record_id').notNullable();
314
+ t.integer('assignee_id').notNullable().references('id').inTable('users').onDelete('RESTRICT');
315
+ t.integer('assigned_by').references('id').inTable('users').onDelete('RESTRICT');
316
+ t.string('kind', 20).notNullable().defaultTo('task');
317
+ t.string('title', 200).notNullable();
318
+ t.text('note');
319
+ t.date('due_on');
320
+ t.string('status', 20).notNullable().defaultTo('open');
321
+ t.uuid('workflow_run_id');
322
+ t.string('workflow_step', 100);
323
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
324
+ t.timestamp('completed_at', { useTz: true });
325
+ t.integer('completed_by').references('id').inTable('users').onDelete('RESTRICT');
326
+ t.string('outcome', 20);
327
+ t.index(['assignee_id', 'status', 'id']);
328
+ t.index(['resource', 'record_id']);
329
+ t.index(['workflow_run_id']);
330
+ });
331
+ }
332
+ /**
333
+ * Message templates, notification e-mail delivery state and the realtime signal.
334
+ * The trigger's NOTIFY is delivered only when the inserting transaction commits.
335
+ */
336
+ export async function createMessagingSchema(db) {
337
+ await db.schema.createTable('message_templates', (t) => {
338
+ t.string('key', 100).primary();
339
+ t.string('subject', 255).notNullable();
340
+ t.text('body').notNullable();
341
+ t.boolean('mail').notNullable().defaultTo(false);
342
+ t.integer('updated_by').references('id').inTable('users').onDelete('SET NULL');
343
+ t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
344
+ });
345
+ await db.schema.alterTable('notifications', (t) => {
346
+ t.string('template_key', 100);
347
+ t.string('mail_state', 20);
348
+ t.integer('mail_attempts').notNullable().defaultTo(0);
349
+ t.string('mail_error', 500);
350
+ t.timestamp('mailed_at', { useTz: true });
351
+ });
352
+ await db.raw("CREATE INDEX notifications_mail_pending ON notifications (id) WHERE mail_state = 'pending'");
353
+ await db.raw(`CREATE FUNCTION kit_notification_signal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_notify('kit_notifications', json_build_object('userId', NEW.user_id, 'id', NEW.id)::text); RETURN NULL; END $$`);
354
+ await db.raw('CREATE TRIGGER kit_notification_signal AFTER INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION kit_notification_signal()');
355
+ }
356
+ /** Outgoing webhooks and their delivery log (exactly one row per webhook and event). */
357
+ export async function createWebhooksSchema(db) {
358
+ await db.schema.createTable('webhooks', (t) => {
359
+ t.increments('id');
360
+ t.string('name', 100).notNullable();
361
+ t.string('url', 2000).notNullable();
362
+ t.text('secret').notNullable();
363
+ t.jsonb('events').notNullable();
364
+ t.boolean('active').notNullable().defaultTo(true);
365
+ t.integer('failing').notNullable().defaultTo(0);
366
+ t.integer('created_by').references('id').inTable('users').onDelete('SET NULL');
367
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
368
+ t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
369
+ t.timestamp('last_delivery_at', { useTz: true });
370
+ });
371
+ await db.raw('CREATE INDEX webhooks_events_gin ON webhooks USING gin(events)');
372
+ await db.schema.createTable('webhook_deliveries', (t) => {
373
+ t.uuid('id').primary();
374
+ t.integer('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
375
+ t.uuid('event_id').notNullable();
376
+ t.string('event').notNullable();
377
+ t.jsonb('payload').notNullable();
378
+ t.string('status', 20).notNullable().defaultTo('pending');
379
+ t.integer('attempts').notNullable().defaultTo(0);
380
+ t.integer('last_status');
381
+ t.string('last_error', 500);
382
+ t.timestamp('next_attempt_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
383
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
384
+ t.timestamp('delivered_at', { useTz: true });
385
+ t.unique(['webhook_id', 'event_id']);
386
+ t.index(['webhook_id', 'created_at']);
387
+ });
388
+ await db.raw("CREATE INDEX webhook_deliveries_due ON webhook_deliveries (next_attempt_at) WHERE status = 'pending'");
389
+ }
390
+ /** CSV import batches: parsed rows, column mapping, progress and per-row errors. */
391
+ export async function createImportsSchema(db) {
392
+ await db.schema.createTable('import_batches', (t) => {
393
+ t.increments('id');
394
+ t.string('resource').notNullable();
395
+ t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
396
+ t.string('file_name', 200).notNullable();
397
+ t.string('status', 20).notNullable();
398
+ t.jsonb('headers').notNullable();
399
+ t.jsonb('mapping').notNullable();
400
+ t.jsonb('rows').notNullable();
401
+ t.integer('total').notNullable();
402
+ t.integer('processed').notNullable().defaultTo(0);
403
+ t.integer('created').notNullable().defaultTo(0);
404
+ t.integer('failed').notNullable().defaultTo(0);
405
+ t.jsonb('errors').notNullable().defaultTo('[]');
406
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
407
+ t.timestamp('started_at', { useTz: true });
408
+ t.timestamp('finished_at', { useTz: true });
409
+ t.index(['user_id', 'id']);
410
+ t.index(['status']);
411
+ });
412
+ }
413
+ /**
414
+ * Workflow engine state (phase 4): the submission envelope rows gain execution
415
+ * columns, and every transition is appended to workflow_events.
416
+ */
417
+ export async function createWorkflowSchema(db) {
418
+ await db.schema.alterTable('workflow_runs', (t) => {
419
+ t.string('current_step', 100);
420
+ t.timestamp('wake_at', { useTz: true });
421
+ t.integer('attempts').notNullable().defaultTo(0);
422
+ t.string('last_error', 1000);
423
+ t.string('outcome', 30);
424
+ t.integer('started_by').references('id').inTable('users').onDelete('SET NULL');
425
+ t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
426
+ t.timestamp('completed_at', { useTz: true });
427
+ t.index(['resource', 'record_id']);
428
+ });
429
+ await db.raw("CREATE INDEX workflow_runs_due ON workflow_runs (wake_at) WHERE status = 'running'");
430
+ await db.schema.createTable('workflow_events', (t) => {
431
+ t.bigIncrements('id');
432
+ t.uuid('run_id').notNullable().references('id').inTable('workflow_runs').onDelete('CASCADE');
433
+ t.string('step', 100);
434
+ t.string('event', 50).notNullable();
435
+ t.integer('actor_id').references('id').inTable('users').onDelete('SET NULL');
436
+ t.jsonb('detail').notNullable().defaultTo('{}');
437
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
438
+ t.index(['run_id', 'id']);
439
+ });
440
+ }
@@ -6,6 +6,7 @@ export type DomainEvent = {
6
6
  };
7
7
  export type Listener = {
8
8
  name: string;
9
+ /** The exact event name, or '*' for a catch-all listener (for example webhooks). */
9
10
  event: string;
10
11
  handle(event: DomainEvent, trx: Knex.Transaction): Promise<void>;
11
12
  };
@@ -17,7 +17,7 @@ export async function publishOutbox(db, jobs, limit = 100) {
17
17
  });
18
18
  }
19
19
  export async function consumeEvent(db, listener, event) {
20
- if (listener.event !== event.event)
20
+ if (listener.event !== '*' && listener.event !== event.event)
21
21
  return false;
22
22
  return db.transaction(async (trx) => {
23
23
  const inserted = await trx('processed_events')
@@ -1,5 +1,10 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { Action } from '../resource/types.js';
3
+ export type FieldChange = {
4
+ field: string;
5
+ before: unknown;
6
+ after: unknown;
7
+ };
3
8
  /** Infrastructure writes for a module-owned mutation, inside its existing transaction. */
4
9
  export declare function recordMutation(trx: Knex.Transaction, mutation: {
5
10
  module: string;
@@ -10,4 +15,6 @@ export declare function recordMutation(trx: Knex.Transaction, mutation: {
10
15
  impersonatorId?: number;
11
16
  action: Action;
12
17
  fields: string[];
18
+ /** Before/after values of the changed fields; read back per viewer's field access. */
19
+ changes?: FieldChange[];
13
20
  }): Promise<void>;
@@ -19,7 +19,8 @@ export async function recordMutation(trx, mutation) {
19
19
  actorId: mutation.actorId,
20
20
  ...(mutation.impersonatorId ? { impersonatorId: mutation.impersonatorId } : {}),
21
21
  };
22
- await trx('activities').insert({
22
+ const [activity] = await trx('activities')
23
+ .insert({
23
24
  resource: mutation.resource,
24
25
  record_id: mutation.id,
25
26
  actor_id: mutation.actorId,
@@ -28,7 +29,17 @@ export async function recordMutation(trx, mutation) {
28
29
  fields: mutation.fields,
29
30
  ...(mutation.impersonatorId ? { impersonatedBy: mutation.impersonatorId } : {}),
30
31
  }),
31
- });
32
+ })
33
+ .returning('id');
34
+ if (mutation.changes?.length)
35
+ await trx('field_changes').insert(mutation.changes.map((change) => ({
36
+ activity_id: activity.id,
37
+ resource: mutation.resource,
38
+ record_id: mutation.id,
39
+ field: change.field,
40
+ before: JSON.stringify(change.before ?? null),
41
+ after: JSON.stringify(change.after ?? null),
42
+ })));
32
43
  await trx('outbox').insert({
33
44
  id: eventId,
34
45
  event,
@@ -0,0 +1,74 @@
1
+ import type { Knex } from 'knex';
2
+ import type { Actor } from '../auth/ability.js';
3
+ import type { ResourceService } from '../admin/resource_service.js';
4
+ import type { ResourceRegistry } from '../resource/registry.js';
5
+ import type { Field } from '../resource/types.js';
6
+ export type ImportStatus = 'mapping' | 'queued' | 'running' | 'done' | 'failed';
7
+ export type ImportTarget = {
8
+ key: string;
9
+ label: string;
10
+ type: string;
11
+ required: boolean;
12
+ };
13
+ export type ImportBatch = {
14
+ id: number;
15
+ resource: string;
16
+ resourceLabel: string;
17
+ fileName: string;
18
+ status: ImportStatus;
19
+ headers: string[];
20
+ mapping: Record<string, string>;
21
+ sample: string[][];
22
+ total: number;
23
+ processed: number;
24
+ created: number;
25
+ failed: number;
26
+ errors: {
27
+ row: number;
28
+ message: string;
29
+ }[];
30
+ createdAt: string;
31
+ finishedAt: string | null;
32
+ targets: ImportTarget[];
33
+ };
34
+ export type ActorSource = {
35
+ load(id: number): Promise<Actor>;
36
+ };
37
+ export declare const IMPORT_ROW_LIMIT = 5000;
38
+ /** Converts one CSV cell to the field's stored representation, or throws a readable error. */
39
+ export declare function importCell(field: Field, raw: string, lookups: Map<string, string>): unknown;
40
+ /**
41
+ * CSV imports in batches: the upload is parsed and stored, the user maps columns
42
+ * to writable fields, and a worker saves each row through ResourceService with
43
+ * the importing user's current permissions. Row failures never stop the batch.
44
+ */
45
+ export declare class ImportBatches {
46
+ private db;
47
+ private registry;
48
+ private resources;
49
+ private actors;
50
+ constructor(db: Knex, registry: ResourceRegistry, resources: ResourceService, actors: ActorSource);
51
+ /** Writable, importable fields for this actor. */
52
+ targets(name: string, actor: Actor): ImportTarget[];
53
+ create(name: string, actor: Actor, input: {
54
+ fileName: unknown;
55
+ content: unknown;
56
+ }): Promise<ImportBatch>;
57
+ show(id: unknown, actor: Actor): Promise<ImportBatch>;
58
+ list(actor: Actor): Promise<ImportBatch[]>;
59
+ /** Stores the column mapping and queues the batch for the worker. */
60
+ start(id: unknown, actor: Actor, mapping: unknown): Promise<ImportBatch>;
61
+ /**
62
+ * Worker step: claims one queued or running batch and imports up to `chunk`
63
+ * rows. Progress is committed per row, so a crash resumes after the last one.
64
+ */
65
+ process(chunk?: number): Promise<{
66
+ id: number;
67
+ processed: number;
68
+ created: number;
69
+ failed: number;
70
+ } | null>;
71
+ private safeTargets;
72
+ private owned;
73
+ private present;
74
+ }