@adula/kit 0.2.0-alpha.1
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.
- package/LICENSE.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { identifier, columnName } from '../resource/define_resource.js';
|
|
2
|
+
export async function createCoreSchema(db) {
|
|
3
|
+
await db.raw('CREATE EXTENSION IF NOT EXISTS ltree WITH SCHEMA public');
|
|
4
|
+
await db.schema.createTable('org_units', (t) => {
|
|
5
|
+
t.increments('id');
|
|
6
|
+
t.integer('parent_id').references('id').inTable('org_units').onDelete('RESTRICT');
|
|
7
|
+
t.string('name').notNullable();
|
|
8
|
+
t.string('type').notNullable();
|
|
9
|
+
t.specificType('path', 'ltree').notNullable().unique();
|
|
10
|
+
});
|
|
11
|
+
await db.raw('CREATE INDEX org_units_path_gist ON org_units USING gist(path)');
|
|
12
|
+
await db.schema.createTable('roles', (t) => {
|
|
13
|
+
t.increments('id');
|
|
14
|
+
t.string('name').notNullable().unique();
|
|
15
|
+
t.integer('permission_level').notNullable().defaultTo(0);
|
|
16
|
+
});
|
|
17
|
+
await db.schema.createTable('role_rules', (t) => {
|
|
18
|
+
t.increments('id');
|
|
19
|
+
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE').index();
|
|
20
|
+
t.string('subject').notNullable();
|
|
21
|
+
t.string('action').notNullable();
|
|
22
|
+
t.jsonb('conditions');
|
|
23
|
+
t.jsonb('fields');
|
|
24
|
+
t.boolean('inverted').notNullable().defaultTo(false);
|
|
25
|
+
});
|
|
26
|
+
for (const name of ['user_roles', 'user_org_units']) {
|
|
27
|
+
await db.schema.createTable(name, (t) => {
|
|
28
|
+
t.increments('id');
|
|
29
|
+
t.integer('user_id')
|
|
30
|
+
.notNullable()
|
|
31
|
+
.references('id')
|
|
32
|
+
.inTable('users')
|
|
33
|
+
.onDelete('RESTRICT')
|
|
34
|
+
.index();
|
|
35
|
+
t.integer('org_unit_id').references('id').inTable('org_units').onDelete('RESTRICT').index();
|
|
36
|
+
if (name === 'user_roles')
|
|
37
|
+
t.integer('role_id')
|
|
38
|
+
.notNullable()
|
|
39
|
+
.references('id')
|
|
40
|
+
.inTable('roles')
|
|
41
|
+
.onDelete('RESTRICT')
|
|
42
|
+
.index();
|
|
43
|
+
else
|
|
44
|
+
t.unique(['user_id', 'org_unit_id']);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
await db.schema.createTable('authorization_revision', (t) => {
|
|
48
|
+
t.integer('id').primary();
|
|
49
|
+
t.bigInteger('version').notNullable();
|
|
50
|
+
});
|
|
51
|
+
await db('authorization_revision').insert({ id: 1, version: 1 });
|
|
52
|
+
await db.raw(`CREATE FUNCTION kit_bump_auth_revision() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE authorization_revision SET version=version+1 WHERE id=1; RETURN NULL; END $$`);
|
|
53
|
+
for (const table of ['roles', 'role_rules', 'user_roles', 'user_org_units', 'org_units'])
|
|
54
|
+
await db.raw(`CREATE TRIGGER kit_invalidate_auth AFTER INSERT OR UPDATE OR DELETE ON ${table} FOR EACH STATEMENT EXECUTE FUNCTION kit_bump_auth_revision()`);
|
|
55
|
+
await db.schema.createTable('settings', (t) => {
|
|
56
|
+
t.increments('id');
|
|
57
|
+
t.string('key').notNullable();
|
|
58
|
+
t.string('scope').notNullable().defaultTo('system');
|
|
59
|
+
t.string('scope_id').notNullable().defaultTo('0');
|
|
60
|
+
t.jsonb('value').notNullable();
|
|
61
|
+
t.unique(['key', 'scope', 'scope_id']);
|
|
62
|
+
});
|
|
63
|
+
await db.schema.createTable('lookups', (t) => {
|
|
64
|
+
t.increments('id');
|
|
65
|
+
t.string('group').notNullable();
|
|
66
|
+
t.string('key').notNullable();
|
|
67
|
+
t.string('label_ar').notNullable();
|
|
68
|
+
t.string('label_en').notNullable();
|
|
69
|
+
t.integer('sort').defaultTo(0);
|
|
70
|
+
t.boolean('active').defaultTo(true);
|
|
71
|
+
t.unique(['group', 'key']);
|
|
72
|
+
});
|
|
73
|
+
await db.schema.createTable('sequences', (t) => {
|
|
74
|
+
t.string('key').primary();
|
|
75
|
+
t.bigInteger('value').notNullable().defaultTo(0);
|
|
76
|
+
});
|
|
77
|
+
await db.schema.createTable('activities', (t) => {
|
|
78
|
+
t.bigIncrements('id');
|
|
79
|
+
t.string('resource').notNullable();
|
|
80
|
+
t.integer('record_id').notNullable();
|
|
81
|
+
t.integer('actor_id').notNullable().references('id').inTable('users');
|
|
82
|
+
t.string('action').notNullable();
|
|
83
|
+
t.jsonb('changes').notNullable();
|
|
84
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(db.fn.now());
|
|
85
|
+
t.index(['resource', 'record_id']);
|
|
86
|
+
});
|
|
87
|
+
await db.schema.createTable('outbox', (t) => {
|
|
88
|
+
t.uuid('id').primary();
|
|
89
|
+
t.string('event').notNullable();
|
|
90
|
+
t.jsonb('payload').notNullable();
|
|
91
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(db.fn.now());
|
|
92
|
+
t.timestamp('published_at', { useTz: true });
|
|
93
|
+
t.integer('attempts').notNullable().defaultTo(0);
|
|
94
|
+
t.text('last_error');
|
|
95
|
+
t.index(['published_at', 'created_at']);
|
|
96
|
+
});
|
|
97
|
+
await db.schema.createTable('processed_events', (t) => {
|
|
98
|
+
t.uuid('event_id').notNullable();
|
|
99
|
+
t.string('listener').notNullable();
|
|
100
|
+
t.timestamp('processed_at', { useTz: true }).defaultTo(db.fn.now());
|
|
101
|
+
t.primary(['event_id', 'listener']);
|
|
102
|
+
});
|
|
103
|
+
await db.schema.createTable('notifications', (t) => {
|
|
104
|
+
t.increments('id');
|
|
105
|
+
t.integer('user_id').notNullable().references('id').inTable('users').index();
|
|
106
|
+
t.string('title').notNullable();
|
|
107
|
+
t.text('body').notNullable();
|
|
108
|
+
t.timestamp('read_at', { useTz: true });
|
|
109
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(db.fn.now());
|
|
110
|
+
});
|
|
111
|
+
await db.schema.createTable('workflow_runs', (t) => {
|
|
112
|
+
t.uuid('id').primary();
|
|
113
|
+
t.string('resource').notNullable();
|
|
114
|
+
t.integer('record_id').notNullable();
|
|
115
|
+
t.string('definition').notNullable();
|
|
116
|
+
t.integer('definition_version').notNullable();
|
|
117
|
+
t.jsonb('snapshot').notNullable();
|
|
118
|
+
t.string('status').notNullable();
|
|
119
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(db.fn.now());
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Attachment metadata lives in one table so ownership, restore drills and disk moves see every file. */
|
|
123
|
+
export async function createAttachmentsSchema(db) {
|
|
124
|
+
await db.schema.createTable('attachments', (t) => {
|
|
125
|
+
t.increments('id');
|
|
126
|
+
t.string('disk').notNullable();
|
|
127
|
+
t.string('path', 1024).notNullable();
|
|
128
|
+
t.string('name').notNullable();
|
|
129
|
+
t.string('original_name').notNullable();
|
|
130
|
+
t.bigInteger('size').notNullable();
|
|
131
|
+
t.string('mime_type').notNullable();
|
|
132
|
+
t.string('extname').notNullable();
|
|
133
|
+
t.jsonb('data').notNullable();
|
|
134
|
+
t.integer('uploaded_by')
|
|
135
|
+
.notNullable()
|
|
136
|
+
.references('id')
|
|
137
|
+
.inTable('users')
|
|
138
|
+
.onDelete('RESTRICT')
|
|
139
|
+
.index();
|
|
140
|
+
t.integer('org_unit_id').references('id').inTable('org_units').onDelete('RESTRICT').index();
|
|
141
|
+
t.string('resource');
|
|
142
|
+
t.integer('record_id');
|
|
143
|
+
t.string('field');
|
|
144
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
145
|
+
t.timestamp('deleted_at', { useTz: true });
|
|
146
|
+
t.index(['resource', 'record_id', 'field']);
|
|
147
|
+
t.index(['disk', 'deleted_at']);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export async function createSavedViewsSchema(db) {
|
|
151
|
+
await db.schema.createTable('saved_views', (t) => {
|
|
152
|
+
t.increments('id');
|
|
153
|
+
t.string('resource').notNullable();
|
|
154
|
+
t.string('name').notNullable();
|
|
155
|
+
t.jsonb('query').notNullable();
|
|
156
|
+
t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
|
157
|
+
t.boolean('shared').notNullable().defaultTo(false);
|
|
158
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
159
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
160
|
+
t.unique(['user_id', 'resource', 'name']);
|
|
161
|
+
t.index(['resource', 'shared']);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export async function createResourceTable(db, resource) {
|
|
165
|
+
const table = identifier(resource.name);
|
|
166
|
+
await db.schema.createTable(table, (t) => {
|
|
167
|
+
t.increments('id');
|
|
168
|
+
if (resource.scoped)
|
|
169
|
+
t.integer('org_unit_id')
|
|
170
|
+
.notNullable()
|
|
171
|
+
.references('id')
|
|
172
|
+
.inTable('org_units')
|
|
173
|
+
.onDelete('RESTRICT')
|
|
174
|
+
.index();
|
|
175
|
+
for (const name of ['created_by', 'updated_by'])
|
|
176
|
+
t.integer(name).notNullable().references('id').inTable('users').onDelete('RESTRICT').index();
|
|
177
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
178
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
179
|
+
t.timestamp('deleted_at', { useTz: true });
|
|
180
|
+
if (resource.version)
|
|
181
|
+
t.integer('version').notNullable().defaultTo(1);
|
|
182
|
+
if (resource.submittable) {
|
|
183
|
+
t.smallint('doc_status').notNullable().defaultTo(0);
|
|
184
|
+
t.integer('amended_from_id').references('id').inTable(table).onDelete('RESTRICT').index();
|
|
185
|
+
}
|
|
186
|
+
if (resource.customFields)
|
|
187
|
+
t.jsonb('custom_fields').notNullable().defaultTo('{}');
|
|
188
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
189
|
+
if (field.type === 'hasMany')
|
|
190
|
+
continue;
|
|
191
|
+
const name = field.column ?? columnName(key);
|
|
192
|
+
let column;
|
|
193
|
+
switch (field.type) {
|
|
194
|
+
case 'belongsTo':
|
|
195
|
+
column = t.integer(name);
|
|
196
|
+
column.references('id').inTable(identifier(field.resource)).onDelete('RESTRICT').index();
|
|
197
|
+
break;
|
|
198
|
+
case 'attachment':
|
|
199
|
+
column = t.integer(name);
|
|
200
|
+
column.references('id').inTable('attachments').onDelete('RESTRICT').index();
|
|
201
|
+
break;
|
|
202
|
+
case 'integer':
|
|
203
|
+
column = t.integer(name);
|
|
204
|
+
break;
|
|
205
|
+
case 'money':
|
|
206
|
+
column = t.bigInteger(name);
|
|
207
|
+
break;
|
|
208
|
+
case 'boolean':
|
|
209
|
+
column = t.boolean(name);
|
|
210
|
+
break;
|
|
211
|
+
case 'date':
|
|
212
|
+
column = t.date(name);
|
|
213
|
+
break;
|
|
214
|
+
case 'datetime':
|
|
215
|
+
column = t.timestamp(name, { useTz: true });
|
|
216
|
+
break;
|
|
217
|
+
case 'json':
|
|
218
|
+
column = t.jsonb(name);
|
|
219
|
+
break;
|
|
220
|
+
case 'text':
|
|
221
|
+
column = t.text(name);
|
|
222
|
+
break;
|
|
223
|
+
default:
|
|
224
|
+
column = t.string(name);
|
|
225
|
+
}
|
|
226
|
+
if (field.required || field.sequence)
|
|
227
|
+
column.notNullable();
|
|
228
|
+
}
|
|
229
|
+
if (resource.scoped)
|
|
230
|
+
t.index(['org_unit_id', 'deleted_at']);
|
|
231
|
+
});
|
|
232
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
233
|
+
if (field.unique)
|
|
234
|
+
await db.raw('CREATE UNIQUE INDEX ?? ON ?? (??) WHERE deleted_at IS NULL', [
|
|
235
|
+
`${table}_${columnName(key)}_active_unique`,
|
|
236
|
+
table,
|
|
237
|
+
field.column ?? columnName(key),
|
|
238
|
+
]);
|
|
239
|
+
}
|
|
240
|
+
const searchable = Object.entries(resource.fields).filter(([, f]) => f.searchable);
|
|
241
|
+
if (searchable.length) {
|
|
242
|
+
const expression = searchable
|
|
243
|
+
.map(([key, field]) => `coalesce("${identifier(field.column ?? columnName(key))}", '')`)
|
|
244
|
+
.join(` || ' ' || `);
|
|
245
|
+
await db.raw(`ALTER TABLE "${table}" ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', ${expression})) STORED`);
|
|
246
|
+
await db.raw('CREATE INDEX ?? ON ?? USING gin(search_vector)', [`${table}_search_gin`, table]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
rules: {
|
|
3
|
+
'no-cross-module-controller': {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: {
|
|
8
|
+
forbidden: 'Modules communicate through events, not cross-module controller imports.',
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
return {
|
|
13
|
+
ImportDeclaration(node) {
|
|
14
|
+
const owner = context.filename
|
|
15
|
+
.replaceAll('\\', '/')
|
|
16
|
+
.match(/app\/modules\/([^/]+)\//)?.[1]
|
|
17
|
+
const target = String(node.source.value).match(
|
|
18
|
+
/(?:#modules\/|modules\/)([^/]+)\/controllers\//
|
|
19
|
+
)?.[1]
|
|
20
|
+
if (owner && target && owner !== target)
|
|
21
|
+
context.report({ node, messageId: 'forbidden' })
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
'no-direct-to-json': {
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'problem',
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: { forbidden: 'Use the kit serialize contract rather than toJSON().' },
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
return {
|
|
34
|
+
CallExpression(node) {
|
|
35
|
+
if (
|
|
36
|
+
node.callee.type === 'MemberExpression' &&
|
|
37
|
+
node.callee.property.type === 'Identifier' &&
|
|
38
|
+
node.callee.property.name === 'toJSON'
|
|
39
|
+
)
|
|
40
|
+
context.report({ node, messageId: 'forbidden' })
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
'no-kit-patching': {
|
|
46
|
+
meta: {
|
|
47
|
+
type: 'problem',
|
|
48
|
+
schema: [],
|
|
49
|
+
messages: { forbidden: 'Do not patch installed @adula packages or use patch-package.' },
|
|
50
|
+
},
|
|
51
|
+
create(context) {
|
|
52
|
+
return {
|
|
53
|
+
Literal(node) {
|
|
54
|
+
if (
|
|
55
|
+
typeof node.value === 'string' &&
|
|
56
|
+
/patch-package|node_modules[/\\]@adula/.test(node.value)
|
|
57
|
+
)
|
|
58
|
+
context.report({ node, messageId: 'forbidden' })
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Jobs } from './outbox.js';
|
|
2
|
+
/** Adapt an application's adonis-jobs dispatcher without importing its job classes. */
|
|
3
|
+
export declare class AdonisJobs implements Jobs {
|
|
4
|
+
private dispatchers;
|
|
5
|
+
constructor(dispatchers: Record<string, (payload: Record<string, unknown>, id: string) => Promise<unknown>>);
|
|
6
|
+
dispatch(name: string, payload: Record<string, unknown>, options: {
|
|
7
|
+
id: string;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Adapt an application's adonis-jobs dispatcher without importing its job classes. */
|
|
2
|
+
export class AdonisJobs {
|
|
3
|
+
dispatchers;
|
|
4
|
+
constructor(dispatchers) {
|
|
5
|
+
this.dispatchers = dispatchers;
|
|
6
|
+
}
|
|
7
|
+
async dispatch(name, payload, options) {
|
|
8
|
+
const dispatch = this.dispatchers[name];
|
|
9
|
+
if (!dispatch)
|
|
10
|
+
throw new Error(`No job dispatcher registered for ${name}`);
|
|
11
|
+
await dispatch(payload, options.id);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type DomainEvent = {
|
|
3
|
+
id: string;
|
|
4
|
+
event: string;
|
|
5
|
+
payload: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
export type Listener = {
|
|
8
|
+
name: string;
|
|
9
|
+
event: string;
|
|
10
|
+
handle(event: DomainEvent, trx: Knex.Transaction): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export interface Jobs {
|
|
13
|
+
dispatch(name: string, payload: Record<string, unknown>, options: {
|
|
14
|
+
id: string;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function publishOutbox(db: Knex, jobs: Jobs, limit?: number): Promise<number>;
|
|
18
|
+
export declare function consumeEvent(db: Knex, listener: Listener, event: DomainEvent): Promise<boolean>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export async function publishOutbox(db, jobs, limit = 100) {
|
|
2
|
+
return db.transaction(async (trx) => {
|
|
3
|
+
const events = await trx('outbox')
|
|
4
|
+
.whereNull('published_at')
|
|
5
|
+
.orderBy('created_at')
|
|
6
|
+
.limit(limit)
|
|
7
|
+
.forUpdate()
|
|
8
|
+
.skipLocked();
|
|
9
|
+
for (const event of events) {
|
|
10
|
+
// Queue implementations must use this stable ID. A crash after publish may redeliver.
|
|
11
|
+
await jobs.dispatch('adula.domain_event', event, { id: event.id });
|
|
12
|
+
await trx('outbox')
|
|
13
|
+
.where('id', event.id)
|
|
14
|
+
.update({ published_at: trx.fn.now(), attempts: event.attempts + 1 });
|
|
15
|
+
}
|
|
16
|
+
return events.length;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function consumeEvent(db, listener, event) {
|
|
20
|
+
if (listener.event !== event.event)
|
|
21
|
+
return false;
|
|
22
|
+
return db.transaction(async (trx) => {
|
|
23
|
+
const inserted = await trx('processed_events')
|
|
24
|
+
.insert({ event_id: event.id, listener: listener.name })
|
|
25
|
+
.onConflict(['event_id', 'listener'])
|
|
26
|
+
.ignore()
|
|
27
|
+
.returning('event_id');
|
|
28
|
+
if (!inserted.length)
|
|
29
|
+
return false;
|
|
30
|
+
await listener.handle(event, trx);
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { Action } from '../resource/types.js';
|
|
3
|
+
/** Infrastructure writes for a module-owned mutation, inside its existing transaction. */
|
|
4
|
+
export declare function recordMutation(trx: Knex.Transaction, mutation: {
|
|
5
|
+
module: string;
|
|
6
|
+
resource: string;
|
|
7
|
+
id: unknown;
|
|
8
|
+
actorId: number;
|
|
9
|
+
action: Action;
|
|
10
|
+
fields: string[];
|
|
11
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/** Infrastructure writes for a module-owned mutation, inside its existing transaction. */
|
|
3
|
+
export async function recordMutation(trx, mutation) {
|
|
4
|
+
const past = {
|
|
5
|
+
create: 'created',
|
|
6
|
+
update: 'updated',
|
|
7
|
+
delete: 'deleted',
|
|
8
|
+
submit: 'submitted',
|
|
9
|
+
cancel: 'cancelled',
|
|
10
|
+
amend: 'amended',
|
|
11
|
+
};
|
|
12
|
+
if (!past[mutation.action])
|
|
13
|
+
throw new Error('Unsupported mutation event');
|
|
14
|
+
const eventId = randomUUID();
|
|
15
|
+
const event = `${mutation.module}.${mutation.resource}.${past[mutation.action]}`;
|
|
16
|
+
const payload = {
|
|
17
|
+
resource: mutation.resource,
|
|
18
|
+
id: mutation.id,
|
|
19
|
+
actorId: mutation.actorId,
|
|
20
|
+
};
|
|
21
|
+
await trx('activities').insert({
|
|
22
|
+
resource: mutation.resource,
|
|
23
|
+
record_id: mutation.id,
|
|
24
|
+
actor_id: mutation.actorId,
|
|
25
|
+
action: mutation.action,
|
|
26
|
+
changes: JSON.stringify({ fields: mutation.fields }),
|
|
27
|
+
});
|
|
28
|
+
await trx('outbox').insert({
|
|
29
|
+
id: eventId,
|
|
30
|
+
event,
|
|
31
|
+
payload: JSON.stringify(payload),
|
|
32
|
+
});
|
|
33
|
+
// A durable submission envelope, not an executing workflow definition.
|
|
34
|
+
// The later workflow engine can claim it using the same outbox event ID.
|
|
35
|
+
if (mutation.action === 'submit')
|
|
36
|
+
await trx('workflow_runs').insert({
|
|
37
|
+
id: eventId,
|
|
38
|
+
resource: mutation.resource,
|
|
39
|
+
record_id: mutation.id,
|
|
40
|
+
definition: event,
|
|
41
|
+
definition_version: 1,
|
|
42
|
+
snapshot: JSON.stringify({ kind: 'submission', schemaVersion: 1, eventId, ...payload }),
|
|
43
|
+
status: 'pending_definition',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Tool } from '@jrmc/adonis-mcp';
|
|
2
|
+
import type { ToolContext } from '@jrmc/adonis-mcp/types/context';
|
|
3
|
+
import type { ResourceRuntime } from '../admin/controller.js';
|
|
4
|
+
/** Optional MCP adapter. Actor identity comes only from the host's authenticated context. */
|
|
5
|
+
export declare function createResourceTools(resolveRuntime: (context: ToolContext) => Promise<ResourceRuntime>): {
|
|
6
|
+
ResourceReadTool: new () => Tool;
|
|
7
|
+
ResourceWriteTool: new () => Tool;
|
|
8
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Tool } from '@jrmc/adonis-mcp';
|
|
2
|
+
import { KitError } from '../admin/errors.js';
|
|
3
|
+
function object(value) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
5
|
+
throw new KitError(422, 'E_INPUT', 'Expected an object');
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function recordId(value) {
|
|
9
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1)
|
|
10
|
+
throw new KitError(422, 'E_ID', 'Expected a positive integer record ID');
|
|
11
|
+
return Number(value);
|
|
12
|
+
}
|
|
13
|
+
/** Optional MCP adapter. Actor identity comes only from the host's authenticated context. */
|
|
14
|
+
export function createResourceTools(resolveRuntime) {
|
|
15
|
+
async function execute(context, write) {
|
|
16
|
+
try {
|
|
17
|
+
const args = object(context.args);
|
|
18
|
+
const accepted = write
|
|
19
|
+
? ['resource', 'action', 'id', 'input', 'version']
|
|
20
|
+
: ['resource', 'action', 'id', 'options'];
|
|
21
|
+
if (Object.keys(args).some((key) => !accepted.includes(key)))
|
|
22
|
+
throw new KitError(422, 'E_INPUT', 'Unknown tool argument');
|
|
23
|
+
const runtime = await resolveRuntime(context);
|
|
24
|
+
if (typeof args.resource !== 'string' ||
|
|
25
|
+
!runtime.registry.all().some((r) => r.name === args.resource))
|
|
26
|
+
throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود');
|
|
27
|
+
const name = args.resource;
|
|
28
|
+
const { resources, actor } = runtime;
|
|
29
|
+
let result;
|
|
30
|
+
if (!write && args.action === 'list') {
|
|
31
|
+
const options = args.options === undefined ? {} : object(args.options);
|
|
32
|
+
if (Object.keys(options).some((key) => !['limit', 'cursor', 'search', 'sort', 'direction', 'filters', 'estimate'].includes(key)))
|
|
33
|
+
throw new KitError(422, 'E_INPUT', 'Unknown list option');
|
|
34
|
+
for (const key of ['cursor', 'search', 'sort'])
|
|
35
|
+
if (options[key] !== undefined && typeof options[key] !== 'string')
|
|
36
|
+
throw new KitError(422, 'E_INPUT', `Invalid ${key}`);
|
|
37
|
+
if ((options.limit !== undefined && !Number.isSafeInteger(options.limit)) ||
|
|
38
|
+
(options.direction !== undefined &&
|
|
39
|
+
!['asc', 'desc'].includes(String(options.direction))) ||
|
|
40
|
+
(options.estimate !== undefined && typeof options.estimate !== 'boolean'))
|
|
41
|
+
throw new KitError(422, 'E_INPUT', 'Invalid list options');
|
|
42
|
+
if (options.filters !== undefined)
|
|
43
|
+
object(options.filters);
|
|
44
|
+
result = await resources.list(name, actor, options);
|
|
45
|
+
}
|
|
46
|
+
else if (!write && args.action === 'show') {
|
|
47
|
+
result = await resources.show(name, recordId(args.id), actor);
|
|
48
|
+
}
|
|
49
|
+
else if (!write && args.action === 'editor') {
|
|
50
|
+
result = await resources.editor(name, actor, args.id === undefined ? undefined : recordId(args.id));
|
|
51
|
+
}
|
|
52
|
+
else if (write && (args.action === 'create' || args.action === 'update')) {
|
|
53
|
+
if (args.action === 'create' && args.id !== undefined)
|
|
54
|
+
throw new KitError(422, 'E_INPUT', 'Create cannot specify an ID');
|
|
55
|
+
result = {
|
|
56
|
+
data: await resources.save(name, actor, object(args.input), args.action === 'update' ? recordId(args.id) : undefined),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else if (write &&
|
|
60
|
+
(args.action === 'delete' || args.action === 'submit' || args.action === 'cancel')) {
|
|
61
|
+
result = {
|
|
62
|
+
data: await resources.transition(name, recordId(args.id), actor, args.action, args.version),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
throw new KitError(422, 'E_ACTION', 'Unsupported resource action');
|
|
67
|
+
return context.response.text(JSON.stringify(result));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof KitError)
|
|
71
|
+
return context.response.error(JSON.stringify({ status: error.status, code: error.code, message: error.message }));
|
|
72
|
+
if (error?.code === 'E_VALIDATION_ERROR')
|
|
73
|
+
return context.response.error(JSON.stringify({
|
|
74
|
+
status: 422,
|
|
75
|
+
code: 'E_VALIDATION_ERROR',
|
|
76
|
+
messages: error.messages,
|
|
77
|
+
}));
|
|
78
|
+
if (['23505', '23503'].includes(error?.code ?? ''))
|
|
79
|
+
return context.response.error(JSON.stringify({
|
|
80
|
+
status: 422,
|
|
81
|
+
code: 'E_CONSTRAINT',
|
|
82
|
+
message: 'تحقق من القيم والسجلات المرتبطة',
|
|
83
|
+
}));
|
|
84
|
+
// Do not put database statements, credentials or stack traces on the wire.
|
|
85
|
+
return context.response.error(JSON.stringify({ status: 500, code: 'E_INTERNAL', message: 'تعذر إتمام العملية' }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
class ResourceReadTool extends Tool {
|
|
89
|
+
name = 'adula_resource_read';
|
|
90
|
+
title = 'Read authorized resources';
|
|
91
|
+
description = 'List or show records, or obtain an authorized editor schema. Organizational scope and field permissions always apply.';
|
|
92
|
+
annotations = {
|
|
93
|
+
readOnlyHint: true,
|
|
94
|
+
destructiveHint: false,
|
|
95
|
+
idempotentHint: true,
|
|
96
|
+
openWorldHint: false,
|
|
97
|
+
};
|
|
98
|
+
schema() {
|
|
99
|
+
return {
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
properties: {
|
|
103
|
+
resource: { type: 'string' },
|
|
104
|
+
action: { type: 'string', enum: ['list', 'show', 'editor'] },
|
|
105
|
+
id: { type: 'integer', minimum: 1 },
|
|
106
|
+
options: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
|
110
|
+
cursor: { type: 'string' },
|
|
111
|
+
search: { type: 'string' },
|
|
112
|
+
sort: { type: 'string' },
|
|
113
|
+
direction: { type: 'string', enum: ['asc', 'desc'] },
|
|
114
|
+
filters: { type: 'object', properties: {}, additionalProperties: true },
|
|
115
|
+
estimate: { type: 'boolean' },
|
|
116
|
+
},
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ['resource', 'action'],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async handle(context) {
|
|
124
|
+
return execute(context, false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class ResourceWriteTool extends Tool {
|
|
128
|
+
name = 'adula_resource_write';
|
|
129
|
+
title = 'Change authorized resources';
|
|
130
|
+
description = 'Create, update, soft-delete, submit or cancel a record. Use editor metadata; update input must include the current version. Transition version is a top-level argument. Writes generate activity and outbox events atomically.';
|
|
131
|
+
annotations = {
|
|
132
|
+
readOnlyHint: false,
|
|
133
|
+
destructiveHint: true,
|
|
134
|
+
idempotentHint: false,
|
|
135
|
+
openWorldHint: false,
|
|
136
|
+
};
|
|
137
|
+
schema() {
|
|
138
|
+
return {
|
|
139
|
+
type: 'object',
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
properties: {
|
|
142
|
+
resource: { type: 'string' },
|
|
143
|
+
action: { type: 'string', enum: ['create', 'update', 'delete', 'submit', 'cancel'] },
|
|
144
|
+
id: { type: 'integer', minimum: 1 },
|
|
145
|
+
input: { type: 'object', properties: {}, additionalProperties: true },
|
|
146
|
+
version: { type: 'integer', minimum: 1 },
|
|
147
|
+
},
|
|
148
|
+
required: ['resource', 'action'],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async handle(context) {
|
|
152
|
+
return execute(context, true);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ResourceReadTool, ResourceWriteTool };
|
|
156
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
export async function moveOrgUnit(db, id, parentId) {
|
|
3
|
+
await db.transaction(async (trx) => {
|
|
4
|
+
// Serialize tree edits; concurrent sibling/ancestor moves cannot corrupt paths.
|
|
5
|
+
await trx.raw('SELECT pg_advisory_xact_lock(717010)');
|
|
6
|
+
const unit = await trx('org_units').where({ id }).forUpdate().first();
|
|
7
|
+
const parent = parentId === null ? null : await trx('org_units').where({ id: parentId }).forUpdate().first();
|
|
8
|
+
if (!unit || (parentId !== null && !parent))
|
|
9
|
+
throw new KitError(404, 'E_ORG_NOT_FOUND', 'Organization unit not found');
|
|
10
|
+
if (parent && (parent.path === unit.path || parent.path.startsWith(`${unit.path}.`)))
|
|
11
|
+
throw new KitError(422, 'E_ORG_CYCLE', 'A unit cannot move beneath itself');
|
|
12
|
+
const next = parent ? `${parent.path}.${id}` : `${id}`;
|
|
13
|
+
await trx.raw('UPDATE org_units SET path = ?::ltree || subpath(path,nlevel(?::ltree)) WHERE path <@ ?::ltree AND id <> ?', [next, unit.path, unit.path, id]);
|
|
14
|
+
await trx('org_units').where({ id }).update({ parent_id: parentId, path: next });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Field, Resource, ResourceInput } from './types.js';
|
|
2
|
+
export declare function identifier(value: string): string;
|
|
3
|
+
export declare function columnName(name: string): string;
|
|
4
|
+
export declare function defineResource<const F extends Record<string, Field>>(input: ResourceInput<F>): ResourceInput<F> & Resource;
|