@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,390 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import User from '#models/user'
|
|
3
|
+
import app from '@adonisjs/core/services/app'
|
|
4
|
+
import db from '@adonisjs/lucid/services/db'
|
|
5
|
+
import { kit } from '#services/kit'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { columnName } from '@adula/kit'
|
|
8
|
+
import type { Field, RecordData, Resource, SerializedRecord } from '@adula/kit'
|
|
9
|
+
|
|
10
|
+
export type FixtureContext = { userId: number; orgUnitId: number; unique: string }
|
|
11
|
+
export type ContractFixture = {
|
|
12
|
+
input: RecordData
|
|
13
|
+
/** Expected public values after validation; relations have separate tests. */
|
|
14
|
+
expected: SerializedRecord
|
|
15
|
+
/** Normalized database values for writable fields excluded from the response. */
|
|
16
|
+
stored?: RecordData
|
|
17
|
+
/** All live child rows after creation, ordered by ID, using public field names. */
|
|
18
|
+
inline?: Record<string, RecordData[]>
|
|
19
|
+
/** A valid update for this same record, including required validator fields. */
|
|
20
|
+
update: RecordData
|
|
21
|
+
updated: SerializedRecord
|
|
22
|
+
updatedStored?: RecordData
|
|
23
|
+
updatedInline?: Record<string, RecordData[]>
|
|
24
|
+
}
|
|
25
|
+
export type ResourceFixture = (
|
|
26
|
+
context: FixtureContext
|
|
27
|
+
) => Promise<ContractFixture> | ContractFixture
|
|
28
|
+
|
|
29
|
+
function storedValue(field: Field, value: unknown) {
|
|
30
|
+
if (!(value instanceof Date)) return value
|
|
31
|
+
return field.type === 'date'
|
|
32
|
+
? [
|
|
33
|
+
value.getFullYear(),
|
|
34
|
+
String(value.getMonth() + 1).padStart(2, '0'),
|
|
35
|
+
String(value.getDate()).padStart(2, '0'),
|
|
36
|
+
].join('-')
|
|
37
|
+
: value.toISOString()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fixtures belong to the application and exercise its real validators and HTTP routes. */
|
|
41
|
+
export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
42
|
+
test.group(`HTTP security contract: ${name}`, (group) => {
|
|
43
|
+
let denied: User
|
|
44
|
+
let writer: User
|
|
45
|
+
let reader: User
|
|
46
|
+
let outsider: User
|
|
47
|
+
let orgUnitId: number
|
|
48
|
+
let resource: Resource
|
|
49
|
+
const base = `/resources/${name}`
|
|
50
|
+
const fresh = () => fixture({ userId: writer.id, orgUnitId, unique: randomUUID() })
|
|
51
|
+
const input = (values: RecordData) => ({ ...values, ...(resource.scoped ? { orgUnitId } : {}) })
|
|
52
|
+
const version = (row: SerializedRecord) => (resource.version ? { version: row.version } : {})
|
|
53
|
+
|
|
54
|
+
group.setup(async () => {
|
|
55
|
+
const knex = db.connection().getWriteClient()
|
|
56
|
+
const databaseInfo = await knex.raw('SELECT current_database() AS name')
|
|
57
|
+
const database = databaseInfo.rows[0].name
|
|
58
|
+
if (!app.inTest || !database.endsWith('_test'))
|
|
59
|
+
throw new Error('Resource contracts require a dedicated *_test database')
|
|
60
|
+
resource = kit().registry.get(name)
|
|
61
|
+
const suffix = randomUUID()
|
|
62
|
+
const users = []
|
|
63
|
+
for (const label of ['denied', 'writer', 'reader', 'outside']) {
|
|
64
|
+
users.push(
|
|
65
|
+
await User.create({
|
|
66
|
+
fullName: 'مستخدم الاختبار',
|
|
67
|
+
email: `${label}-${suffix}@example.test`,
|
|
68
|
+
password: 'a-long-test-password-123',
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
;[denied, writer, reader, outsider] = users
|
|
73
|
+
const [org, outside] = await knex('org_units')
|
|
74
|
+
.insert([
|
|
75
|
+
{ name: 'نطاق الاختبار', type: 'root', path: `contract_${suffix.replaceAll('-', '_')}` },
|
|
76
|
+
{ name: 'نطاق آخر', type: 'root', path: `outside_${suffix.replaceAll('-', '_')}` },
|
|
77
|
+
])
|
|
78
|
+
.returning('id')
|
|
79
|
+
orgUnitId = org.id
|
|
80
|
+
const [writeRole, readRole] = await knex('roles')
|
|
81
|
+
.insert([
|
|
82
|
+
{ name: `writer-${suffix}`, permission_level: 1 },
|
|
83
|
+
{ name: `reader-${suffix}`, permission_level: 0 },
|
|
84
|
+
])
|
|
85
|
+
.returning('id')
|
|
86
|
+
await knex('role_rules').insert([
|
|
87
|
+
{ role_id: writeRole.id, subject: 'all', action: 'manage' },
|
|
88
|
+
{ role_id: readRole.id, subject: 'all', action: 'view' },
|
|
89
|
+
])
|
|
90
|
+
await knex('user_roles').insert([
|
|
91
|
+
{ user_id: writer.id, role_id: writeRole.id },
|
|
92
|
+
{ user_id: outsider.id, role_id: writeRole.id },
|
|
93
|
+
{ user_id: reader.id, role_id: readRole.id },
|
|
94
|
+
])
|
|
95
|
+
await knex('user_org_units').insert([
|
|
96
|
+
{ user_id: writer.id, org_unit_id: orgUnitId },
|
|
97
|
+
{ user_id: reader.id, org_unit_id: orgUnitId },
|
|
98
|
+
{ user_id: outsider.id, org_unit_id: outside.id },
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
for (const [method, suffix] of [
|
|
103
|
+
['get', ''],
|
|
104
|
+
['get', '/create'],
|
|
105
|
+
['get', '/999999/edit'],
|
|
106
|
+
['get', '/999999'],
|
|
107
|
+
['post', ''],
|
|
108
|
+
['patch', '/999999'],
|
|
109
|
+
['delete', '/999999'],
|
|
110
|
+
['post', '/999999/submit'],
|
|
111
|
+
['post', '/999999/cancel'],
|
|
112
|
+
] as const) {
|
|
113
|
+
test(`${method.toUpperCase()} ${suffix || '/'} returns 403 without roles`, async ({
|
|
114
|
+
client,
|
|
115
|
+
}) => {
|
|
116
|
+
const response = await client[method](`${base}${suffix}`)
|
|
117
|
+
.loginAs(denied)
|
|
118
|
+
.withCsrfToken()
|
|
119
|
+
.header('Accept', 'application/json')
|
|
120
|
+
response.assertStatus(403)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
test('resource route is protected from anonymous access', async ({ client }) => {
|
|
124
|
+
const response = await client.get(base).header('Accept', 'application/json')
|
|
125
|
+
response.assertStatus(401)
|
|
126
|
+
})
|
|
127
|
+
test('existing records enforce scope on reads and writes; central resources stay shared', async ({
|
|
128
|
+
client,
|
|
129
|
+
assert,
|
|
130
|
+
}) => {
|
|
131
|
+
const values = await fresh()
|
|
132
|
+
const created = await client
|
|
133
|
+
.post(base)
|
|
134
|
+
.loginAs(writer)
|
|
135
|
+
.withCsrfToken()
|
|
136
|
+
.header('Accept', 'application/json')
|
|
137
|
+
.json(input(values.input))
|
|
138
|
+
created.assertStatus(201)
|
|
139
|
+
const row = created.body().data
|
|
140
|
+
const visible = await client
|
|
141
|
+
.get(`${base}/${row.id}`)
|
|
142
|
+
.loginAs(writer)
|
|
143
|
+
.header('Accept', 'application/json')
|
|
144
|
+
visible.assertStatus(200)
|
|
145
|
+
assert.equal(visible.body().data.id, row.id)
|
|
146
|
+
const outside = await client
|
|
147
|
+
.get(`${base}/${row.id}`)
|
|
148
|
+
.loginAs(outsider)
|
|
149
|
+
.header('Accept', 'application/json')
|
|
150
|
+
outside.assertStatus(resource.scoped ? 404 : 200)
|
|
151
|
+
if (!resource.scoped) return
|
|
152
|
+
const listing = await client.get(base).loginAs(outsider).header('Accept', 'application/json')
|
|
153
|
+
listing.assertStatus(200)
|
|
154
|
+
assert.notInclude(
|
|
155
|
+
listing.body().data.map((entry: SerializedRecord) => entry.id),
|
|
156
|
+
row.id
|
|
157
|
+
)
|
|
158
|
+
const edit = await client
|
|
159
|
+
.get(`${base}/${row.id}/edit`)
|
|
160
|
+
.loginAs(outsider)
|
|
161
|
+
.header('Accept', 'application/json')
|
|
162
|
+
edit.assertStatus(404)
|
|
163
|
+
const changed = await client
|
|
164
|
+
.patch(`${base}/${row.id}`)
|
|
165
|
+
.loginAs(outsider)
|
|
166
|
+
.withCsrfToken()
|
|
167
|
+
.header('Accept', 'application/json')
|
|
168
|
+
.json({ ...values.update, ...version(row) })
|
|
169
|
+
changed.assertStatus(404)
|
|
170
|
+
const deleted = await client
|
|
171
|
+
.delete(`${base}/${row.id}`)
|
|
172
|
+
.loginAs(outsider)
|
|
173
|
+
.withCsrfToken()
|
|
174
|
+
.header('Accept', 'application/json')
|
|
175
|
+
.json(version(row))
|
|
176
|
+
deleted.assertStatus(404)
|
|
177
|
+
const after = await client
|
|
178
|
+
.get(`${base}/${row.id}`)
|
|
179
|
+
.loginAs(writer)
|
|
180
|
+
.header('Accept', 'application/json')
|
|
181
|
+
after.assertStatus(200)
|
|
182
|
+
assert.deepEqual(after.body().data, row)
|
|
183
|
+
})
|
|
184
|
+
test('fixture fields round-trip, private values stay hidden, updates and soft deletion persist', async ({
|
|
185
|
+
client,
|
|
186
|
+
assert,
|
|
187
|
+
}) => {
|
|
188
|
+
const values = await fresh()
|
|
189
|
+
for (const key of resource.form)
|
|
190
|
+
assert.property(values.input, key, `Missing ${name}.${key} fixture`)
|
|
191
|
+
const created = await client
|
|
192
|
+
.post(base)
|
|
193
|
+
.loginAs(writer)
|
|
194
|
+
.withCsrfToken()
|
|
195
|
+
.header('Accept', 'application/json')
|
|
196
|
+
.json(input(values.input))
|
|
197
|
+
created.assertStatus(201)
|
|
198
|
+
const row = created.body().data
|
|
199
|
+
assert.isNumber(row.id)
|
|
200
|
+
for (const [key, value] of Object.entries(values.expected))
|
|
201
|
+
assert.deepEqual(row[key], value, key)
|
|
202
|
+
const exposed = new Set([
|
|
203
|
+
'id',
|
|
204
|
+
...(resource.serialize ?? [...resource.list, ...resource.show]),
|
|
205
|
+
...(resource.scoped ? ['orgUnitId'] : []),
|
|
206
|
+
...(resource.version ? ['version'] : []),
|
|
207
|
+
...(resource.submittable ? ['docStatus'] : []),
|
|
208
|
+
])
|
|
209
|
+
for (const key of Object.keys(row))
|
|
210
|
+
assert.isTrue(exposed.has(key), `Unexpected serialized field ${key}`)
|
|
211
|
+
const assertStored = async (
|
|
212
|
+
submitted: RecordData,
|
|
213
|
+
expected: SerializedRecord,
|
|
214
|
+
stored: RecordData = {},
|
|
215
|
+
inline: Record<string, RecordData[]> = {}
|
|
216
|
+
) => {
|
|
217
|
+
const persistedRow = await db
|
|
218
|
+
.connection()
|
|
219
|
+
.getWriteClient()(name)
|
|
220
|
+
.where('id', row.id)
|
|
221
|
+
.first()
|
|
222
|
+
const expectations = { ...expected, ...stored }
|
|
223
|
+
for (const key of Object.keys(submitted)) {
|
|
224
|
+
if (!resource.form.includes(key)) continue
|
|
225
|
+
const field = resource.fields[key]
|
|
226
|
+
if (field.type === 'hasMany') {
|
|
227
|
+
assert.property(inline, key, `Missing expected ${name}.${key} child rows`)
|
|
228
|
+
const child = kit().registry.get(field.resource)
|
|
229
|
+
const foreignKey = child.fields[field.foreignKey].column ?? columnName(field.foreignKey)
|
|
230
|
+
const rows = await db
|
|
231
|
+
.connection()
|
|
232
|
+
.getWriteClient()(child.name)
|
|
233
|
+
.where(foreignKey, row.id)
|
|
234
|
+
.whereNull('deleted_at')
|
|
235
|
+
.orderBy('id')
|
|
236
|
+
assert.lengthOf(rows, inline[key].length)
|
|
237
|
+
for (const [index, entry] of inline[key].entries()) {
|
|
238
|
+
for (const childKey of child.form.filter((entryKey) => entryKey !== field.foreignKey))
|
|
239
|
+
assert.property(entry, childKey, `Missing expected ${field.resource}.${childKey}`)
|
|
240
|
+
for (const [childKey, value] of Object.entries(entry)) {
|
|
241
|
+
const childField = child.fields[childKey]
|
|
242
|
+
assert.exists(childField, `Unknown expected child field ${childKey}`)
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
storedValue(childField, rows[index][childField.column ?? columnName(childKey)]),
|
|
245
|
+
value
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
assert.property(expectations, key, `Missing stored ${name}.${key} expectation`)
|
|
251
|
+
if (exposed.has(key))
|
|
252
|
+
assert.property(expected, key, `Missing public ${name}.${key} expectation`)
|
|
253
|
+
assert.deepEqual(
|
|
254
|
+
storedValue(field, persistedRow[field.column ?? columnName(key)]),
|
|
255
|
+
expectations[key],
|
|
256
|
+
key
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
await assertStored(values.input, values.expected, values.stored, values.inline)
|
|
262
|
+
const shown = await client
|
|
263
|
+
.get(`${base}/${row.id}`)
|
|
264
|
+
.loginAs(reader)
|
|
265
|
+
.header('Accept', 'application/json')
|
|
266
|
+
shown.assertStatus(200)
|
|
267
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
268
|
+
if (field.permissionLevel || resource.hidden?.includes(key))
|
|
269
|
+
assert.notProperty(shown.body().data, key)
|
|
270
|
+
}
|
|
271
|
+
for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {
|
|
272
|
+
const invalid = await client
|
|
273
|
+
.patch(`${base}/${row.id}`)
|
|
274
|
+
.loginAs(writer)
|
|
275
|
+
.withCsrfToken()
|
|
276
|
+
.header('Accept', 'application/json')
|
|
277
|
+
.json({ ...values.update, ...version(row), [key]: writer.id })
|
|
278
|
+
invalid.assertStatus(422)
|
|
279
|
+
assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')
|
|
280
|
+
}
|
|
281
|
+
const updated = await client
|
|
282
|
+
.patch(`${base}/${row.id}`)
|
|
283
|
+
.loginAs(writer)
|
|
284
|
+
.withCsrfToken()
|
|
285
|
+
.header('Accept', 'application/json')
|
|
286
|
+
.json({ ...values.update, ...version(row) })
|
|
287
|
+
updated.assertStatus(200)
|
|
288
|
+
for (const [key, value] of Object.entries(values.updated))
|
|
289
|
+
assert.deepEqual(updated.body().data[key], value, key)
|
|
290
|
+
await assertStored(values.update, values.updated, values.updatedStored, values.updatedInline)
|
|
291
|
+
if (resource.version) {
|
|
292
|
+
assert.equal(updated.body().data.version, Number(row.version) + 1)
|
|
293
|
+
const stale = await client
|
|
294
|
+
.patch(`${base}/${row.id}`)
|
|
295
|
+
.loginAs(writer)
|
|
296
|
+
.withCsrfToken()
|
|
297
|
+
.header('Accept', 'application/json')
|
|
298
|
+
.json({ ...values.update, ...version(row) })
|
|
299
|
+
stale.assertStatus(409)
|
|
300
|
+
}
|
|
301
|
+
const persisted = await client
|
|
302
|
+
.get(`${base}/${row.id}`)
|
|
303
|
+
.loginAs(writer)
|
|
304
|
+
.header('Accept', 'application/json')
|
|
305
|
+
persisted.assertStatus(200)
|
|
306
|
+
assert.deepEqual(persisted.body().data, updated.body().data)
|
|
307
|
+
const removed = await client
|
|
308
|
+
.delete(`${base}/${row.id}`)
|
|
309
|
+
.loginAs(writer)
|
|
310
|
+
.withCsrfToken()
|
|
311
|
+
.header('Accept', 'application/json')
|
|
312
|
+
.json(version(updated.body().data))
|
|
313
|
+
removed.assertStatus(200)
|
|
314
|
+
const stored = await db.connection().getWriteClient()(name).where('id', row.id).first()
|
|
315
|
+
assert.exists(stored.deleted_at)
|
|
316
|
+
assert.equal(stored.created_by, writer.id)
|
|
317
|
+
const missing = await client
|
|
318
|
+
.get(`${base}/${row.id}`)
|
|
319
|
+
.loginAs(writer)
|
|
320
|
+
.header('Accept', 'application/json')
|
|
321
|
+
missing.assertStatus(404)
|
|
322
|
+
})
|
|
323
|
+
test('unique constraints reject duplicates and allow reuse after soft deletion', async ({
|
|
324
|
+
client,
|
|
325
|
+
assert,
|
|
326
|
+
}) => {
|
|
327
|
+
const keys = Object.entries(resource.fields).filter(([, field]) => field.unique)
|
|
328
|
+
if (!keys.length) return
|
|
329
|
+
const values = await fresh()
|
|
330
|
+
const created = await client
|
|
331
|
+
.post(base)
|
|
332
|
+
.loginAs(writer)
|
|
333
|
+
.withCsrfToken()
|
|
334
|
+
.header('Accept', 'application/json')
|
|
335
|
+
.json(input(values.input))
|
|
336
|
+
created.assertStatus(201)
|
|
337
|
+
const row = created.body().data
|
|
338
|
+
const knex = db.connection().getWriteClient()
|
|
339
|
+
const stored = await knex(name).where('id', row.id).first()
|
|
340
|
+
const distinct = await fresh()
|
|
341
|
+
const second = await client
|
|
342
|
+
.post(base)
|
|
343
|
+
.loginAs(writer)
|
|
344
|
+
.withCsrfToken()
|
|
345
|
+
.header('Accept', 'application/json')
|
|
346
|
+
.json(input(distinct.input))
|
|
347
|
+
second.assertStatus(201)
|
|
348
|
+
// Read-only sequence values also require real PostgreSQL uniqueness assertions.
|
|
349
|
+
for (const [key, field] of keys) {
|
|
350
|
+
const column = field.column ?? columnName(key)
|
|
351
|
+
assert.isNotNull(stored[column], `Missing unique fixture ${key}`)
|
|
352
|
+
await assert.rejects(
|
|
353
|
+
() =>
|
|
354
|
+
knex(name)
|
|
355
|
+
.where('id', second.body().data.id)
|
|
356
|
+
.update({ [column]: stored[column] }),
|
|
357
|
+
/duplicate key/
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
for (const [key, field] of keys) {
|
|
361
|
+
if (field.sequence || !resource.form.includes(key)) continue
|
|
362
|
+
const other = await fresh()
|
|
363
|
+
const rejected = await client
|
|
364
|
+
.post(base)
|
|
365
|
+
.loginAs(writer)
|
|
366
|
+
.withCsrfToken()
|
|
367
|
+
.header('Accept', 'application/json')
|
|
368
|
+
.json(input({ ...other.input, [key]: values.input[key] }))
|
|
369
|
+
rejected.assertStatus(409)
|
|
370
|
+
assert.equal(rejected.body().error.code, 'E_DUPLICATE')
|
|
371
|
+
}
|
|
372
|
+
const removed = await client
|
|
373
|
+
.delete(`${base}/${row.id}`)
|
|
374
|
+
.loginAs(writer)
|
|
375
|
+
.withCsrfToken()
|
|
376
|
+
.header('Accept', 'application/json')
|
|
377
|
+
.json(version(row))
|
|
378
|
+
removed.assertStatus(200)
|
|
379
|
+
for (const [key, field] of keys) {
|
|
380
|
+
const column = field.column ?? columnName(key)
|
|
381
|
+
assert.equal(
|
|
382
|
+
await knex(name)
|
|
383
|
+
.where('id', second.body().data.id)
|
|
384
|
+
.update({ [column]: stored[column] }),
|
|
385
|
+
1
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{{{ exports({ to: app.makePath('start/kit_routes.ts') }) }}}
|
|
2
|
+
import router from '@adonisjs/core/services/router'
|
|
3
|
+
import { middleware } from '#start/kernel'
|
|
4
|
+
const ResourcesController = () => import('#controllers/resources_controller')
|
|
5
|
+
|
|
6
|
+
router.group(() => {
|
|
7
|
+
router.get('/resources/:resource', [ResourcesController, 'index'])
|
|
8
|
+
router.get('/resources/:resource/create', [ResourcesController, 'create'])
|
|
9
|
+
router.get('/resources/:resource/:id/edit', [ResourcesController, 'edit'])
|
|
10
|
+
router.get('/resources/:resource/:id', [ResourcesController, 'show'])
|
|
11
|
+
router.post('/resources/:resource', [ResourcesController, 'store'])
|
|
12
|
+
router.patch('/resources/:resource/:id', [ResourcesController, 'update'])
|
|
13
|
+
router.delete('/resources/:resource/:id', [ResourcesController, 'destroy'])
|
|
14
|
+
router.post('/resources/:resource/:id/submit', [ResourcesController, 'submit'])
|
|
15
|
+
router.post('/resources/:resource/:id/cancel', [ResourcesController, 'cancel'])
|
|
16
|
+
}).use(middleware.auth())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{{{ exports({ to: app.makePath('app/services/kit.ts') }) }}}
|
|
2
|
+
import db from '@adonisjs/lucid/services/db'
|
|
3
|
+
import { ActorStore, ResourceService } from '@adula/kit'
|
|
4
|
+
import { registry } from '#start/modules'
|
|
5
|
+
|
|
6
|
+
export function kit() {
|
|
7
|
+
const knex = db.connection().getWriteClient()
|
|
8
|
+
return { registry, resources: new ResourceService(knex, registry), actors: new ActorStore(knex, registry) }
|
|
9
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adula/kit",
|
|
3
|
+
"description": "Resource-driven business applications for AdonisJS 7",
|
|
4
|
+
"version": "0.2.0-alpha.1",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.0.0"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": [
|
|
10
|
+
"build",
|
|
11
|
+
"!build/bin",
|
|
12
|
+
"!build/tests"
|
|
13
|
+
],
|
|
14
|
+
"main": "build/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./build/index.js",
|
|
17
|
+
"./types": "./build/src/resource/types.js",
|
|
18
|
+
"./auth": "./build/src/auth/ability.js",
|
|
19
|
+
"./mcp": "./build/src/mcp/tools.js",
|
|
20
|
+
"./provider": "./build/providers/kit_provider.js",
|
|
21
|
+
"./commands": "./build/commands/main.js",
|
|
22
|
+
"./eslint": "./build/src/eslint/index.js",
|
|
23
|
+
"./migrations/*": "./build/database/migrations/*.js",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"adonisjs",
|
|
28
|
+
"business-applications",
|
|
29
|
+
"arabic",
|
|
30
|
+
"rtl",
|
|
31
|
+
"postgresql",
|
|
32
|
+
"authorization"
|
|
33
|
+
],
|
|
34
|
+
"author": "adula contributors",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@adonisjs/assembler": "8.5.0",
|
|
38
|
+
"@adonisjs/core": "7.5.0",
|
|
39
|
+
"@adonisjs/drive": "4.0.0",
|
|
40
|
+
"@adonisjs/eslint-config": "3.1.0",
|
|
41
|
+
"@adonisjs/lucid": "22.4.2",
|
|
42
|
+
"@adonisjs/tsconfig": "2.0.0",
|
|
43
|
+
"@japa/assert": "4.2.0",
|
|
44
|
+
"@japa/runner": "5.3.0",
|
|
45
|
+
"@jrmc/adonis-mcp": "2.0.0",
|
|
46
|
+
"@poppinss/ts-exec": "1.4.4",
|
|
47
|
+
"@types/node": "26.2.0",
|
|
48
|
+
"@ucast/sql": "0.2.0",
|
|
49
|
+
"eslint": "10.9.0",
|
|
50
|
+
"pg": "8.16.3",
|
|
51
|
+
"typescript": "6.0.3"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@adonisjs/assembler": "^8.5.0",
|
|
55
|
+
"@adonisjs/core": "^7.0.0",
|
|
56
|
+
"@adonisjs/drive": "^4.0.0",
|
|
57
|
+
"@adonisjs/lucid": "^22.0.0",
|
|
58
|
+
"@jrmc/adonis-mcp": "^2.0.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"@jrmc/adonis-mcp": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@adonisjs/assembler": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@adonisjs/drive": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public",
|
|
73
|
+
"provenance": true
|
|
74
|
+
},
|
|
75
|
+
"c8": {
|
|
76
|
+
"reporter": [
|
|
77
|
+
"text",
|
|
78
|
+
"html"
|
|
79
|
+
],
|
|
80
|
+
"exclude": [
|
|
81
|
+
"tests/**"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"prettier": "@adonisjs/prettier-config",
|
|
85
|
+
"dependencies": {
|
|
86
|
+
"@casl/ability": "7.0.1",
|
|
87
|
+
"knex": "3.3.0"
|
|
88
|
+
},
|
|
89
|
+
"repository": {
|
|
90
|
+
"type": "git",
|
|
91
|
+
"url": "git+https://github.com/adulash/adula-kit.git",
|
|
92
|
+
"directory": "packages/kit"
|
|
93
|
+
},
|
|
94
|
+
"homepage": "https://github.com/adulash/adula-kit/tree/main/packages/kit#readme",
|
|
95
|
+
"bugs": {
|
|
96
|
+
"url": "https://github.com/adulash/adula-kit/issues"
|
|
97
|
+
},
|
|
98
|
+
"scripts": {
|
|
99
|
+
"build": "node ../../scripts/clean-kit-build.mjs && tsc -p tsconfig.build.json && node ../../scripts/copy-kit-assets.mjs",
|
|
100
|
+
"typecheck": "tsc --noEmit",
|
|
101
|
+
"test": "node --import=@poppinss/ts-exec bin/test.ts",
|
|
102
|
+
"lint": "eslint src providers commands database tests index.ts"
|
|
103
|
+
}
|
|
104
|
+
}
|