spine-framework 0.3.118 → 0.3.120
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.
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module admin-data-shared
|
|
3
|
+
* @audience both
|
|
4
|
+
* @layer api-handler
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Shared runtime-record creation used by `admin-data.ts` and by system-level
|
|
8
|
+
* custom functions that must write runtime records (e.g. CIL links).
|
|
9
|
+
*
|
|
10
|
+
* Centralising the insert path guarantees that every runtime record gets a
|
|
11
|
+
* valid `type_id`, `account_id`, stamped schema, and audit fields.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SupabaseClient } from '@supabase/supabase-js'
|
|
15
|
+
import { adminDb } from './db'
|
|
16
|
+
import { resolveTypeId } from './resolve-ids'
|
|
17
|
+
import { fireCreateTriggers } from './trigger-engine'
|
|
18
|
+
|
|
19
|
+
const PERMISSIONS_ALL = {
|
|
20
|
+
record_permissions: { all: ['create', 'read', 'update', 'delete'] },
|
|
21
|
+
fields: {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VALID_ENTITIES = ['accounts', 'people', 'items', 'threads', 'messages', 'links', 'attachments', 'watchers', 'item_progress']
|
|
25
|
+
|
|
26
|
+
const ENTITY_TO_KIND: Record<string, string> = {
|
|
27
|
+
items: 'item',
|
|
28
|
+
threads: 'thread',
|
|
29
|
+
messages: 'message',
|
|
30
|
+
links: 'link',
|
|
31
|
+
attachments: 'attachment',
|
|
32
|
+
watchers: 'watcher',
|
|
33
|
+
item_progress: 'item_progress'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CreateRecordOptions {
|
|
37
|
+
/** Supabase client to use for the insert (adminDb for system ops, ctx.db for user-scoped) */
|
|
38
|
+
db: SupabaseClient<any, 'public', any>
|
|
39
|
+
/** Target table */
|
|
40
|
+
entity: string
|
|
41
|
+
/** Type slug (e.g. 'link', 'cil_chunk'). Either this or typeId is required. */
|
|
42
|
+
typeSlug?: string
|
|
43
|
+
/** Type UUID. Either this or typeSlug is required. */
|
|
44
|
+
typeId?: string
|
|
45
|
+
/** Account UUID to stamp on the record. If omitted, inherited from parentRef or falls back to system account. */
|
|
46
|
+
accountId?: string | null
|
|
47
|
+
/** Principal ID to stamp as created_by */
|
|
48
|
+
createdBy?: string | null
|
|
49
|
+
/** Record fields to insert */
|
|
50
|
+
data: Record<string, any>
|
|
51
|
+
/** Optional parent reference for account/scope inheritance */
|
|
52
|
+
parentRef?: { id: string; table: 'items' | 'threads' }
|
|
53
|
+
/** If true, skip firing create triggers */
|
|
54
|
+
skipTriggers?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CreateRecordResult {
|
|
58
|
+
id: string
|
|
59
|
+
record: any
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a runtime record with full schema stamping and audit fields.
|
|
64
|
+
*
|
|
65
|
+
* This is the single shared path for writes to runtime tables. Callers choose
|
|
66
|
+
* the client (`adminDb` for system/cron operations, `ctx.db` for RLS-scoped
|
|
67
|
+
* user operations) and the helper handles the rest.
|
|
68
|
+
*/
|
|
69
|
+
export async function createRecord(options: CreateRecordOptions): Promise<CreateRecordResult> {
|
|
70
|
+
const { db, entity, typeSlug, typeId: explicitTypeId, accountId, createdBy, data: recordData, parentRef, skipTriggers } = options
|
|
71
|
+
|
|
72
|
+
if (!entity || !VALID_ENTITIES.includes(entity)) {
|
|
73
|
+
throw new Error(`Valid entity parameter is required: got ${entity}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let typeId = explicitTypeId
|
|
77
|
+
if (!typeId && typeSlug) {
|
|
78
|
+
const kind = ENTITY_TO_KIND[entity]
|
|
79
|
+
if (!kind) {
|
|
80
|
+
throw new Error(`type_slug is not supported for entity: ${entity}`)
|
|
81
|
+
}
|
|
82
|
+
typeId = await resolveTypeId(kind, typeSlug)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!typeId) {
|
|
86
|
+
throw new Error('type_id or type_slug is required — every runtime record must reference a type')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { data: typeRecord, error: typeErr } = await adminDb
|
|
90
|
+
.from('types')
|
|
91
|
+
.select('id, design_schema, validation_schema, is_active')
|
|
92
|
+
.eq('id', typeId)
|
|
93
|
+
.single()
|
|
94
|
+
|
|
95
|
+
if (typeErr || !typeRecord) {
|
|
96
|
+
throw new Error(`type_id not found: ${typeId}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!typeRecord.is_active) {
|
|
100
|
+
throw new Error(`type_id references an inactive type: ${typeId}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let designSchema = typeRecord.design_schema || {}
|
|
104
|
+
if (!designSchema.record_permissions) {
|
|
105
|
+
designSchema = { ...PERMISSIONS_ALL, ...designSchema }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let recordAccountId = accountId ?? null
|
|
109
|
+
if (parentRef) {
|
|
110
|
+
const { data: parentRecord } = await db
|
|
111
|
+
.from(parentRef.table)
|
|
112
|
+
.select('account_id, design_schema')
|
|
113
|
+
.eq('id', parentRef.id)
|
|
114
|
+
.maybeSingle()
|
|
115
|
+
if (!parentRecord) {
|
|
116
|
+
throw new Error('Parent record not found or not accessible')
|
|
117
|
+
}
|
|
118
|
+
recordAccountId = parentRecord.account_id
|
|
119
|
+
const parentScope = parentRecord.design_schema?.scope
|
|
120
|
+
if (parentScope) {
|
|
121
|
+
designSchema = { ...designSchema, scope: parentScope }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const resolvedScope: string = designSchema.scope ?? 'account'
|
|
126
|
+
if (resolvedScope === 'platform') {
|
|
127
|
+
const { data: sysAccount } = await adminDb
|
|
128
|
+
.from('accounts')
|
|
129
|
+
.select('id')
|
|
130
|
+
.eq('slug', 'spine-system')
|
|
131
|
+
.single()
|
|
132
|
+
if (sysAccount?.id) recordAccountId = sysAccount.id
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!recordAccountId) {
|
|
136
|
+
throw new Error('account_id is required — could not resolve from caller or parent')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const dataToInsert = {
|
|
140
|
+
...recordData,
|
|
141
|
+
type_id: typeId,
|
|
142
|
+
design_schema: designSchema,
|
|
143
|
+
validation_schema: typeRecord.validation_schema || {},
|
|
144
|
+
created_by: createdBy ?? null,
|
|
145
|
+
account_id: recordAccountId,
|
|
146
|
+
created_at: new Date().toISOString(),
|
|
147
|
+
updated_at: new Date().toISOString()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { data, error: err } = await db.from(entity)
|
|
151
|
+
.insert(dataToInsert)
|
|
152
|
+
.select()
|
|
153
|
+
.single()
|
|
154
|
+
|
|
155
|
+
if (err) throw err
|
|
156
|
+
if (!data) throw new Error('Insert succeeded but no record returned')
|
|
157
|
+
|
|
158
|
+
if (!skipTriggers) {
|
|
159
|
+
const entityData = { ...dataToInsert, id: data.id }
|
|
160
|
+
fireCreateTriggers(entity, data.id, entityData, {
|
|
161
|
+
principal: { id: createdBy || 'system', type: 'machine', accountId: recordAccountId, provenance: { sourceType: 'manual', createdBy: null, invokedAt: new Date().toISOString() } } as any,
|
|
162
|
+
accountId: recordAccountId,
|
|
163
|
+
db,
|
|
164
|
+
appId: null,
|
|
165
|
+
query: {},
|
|
166
|
+
path: ''
|
|
167
|
+
}).catch(console.error)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { id: data.id, record: data }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface BatchRecord {
|
|
174
|
+
data: Record<string, any>
|
|
175
|
+
accountId?: string | null
|
|
176
|
+
parentRef?: { id: string; table: 'items' | 'threads' }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Batch create runtime records with shared schema stamping.
|
|
181
|
+
*
|
|
182
|
+
* Useful for system operations that create many links/items at once (e.g. CIL
|
|
183
|
+
* link extraction). The type is resolved once and applied to every row.
|
|
184
|
+
*/
|
|
185
|
+
export async function createRecords(options: {
|
|
186
|
+
db: SupabaseClient<any, 'public', any>
|
|
187
|
+
entity: string
|
|
188
|
+
typeSlug?: string
|
|
189
|
+
typeId?: string
|
|
190
|
+
records: BatchRecord[]
|
|
191
|
+
skipTriggers?: boolean
|
|
192
|
+
}): Promise<CreateRecordResult[]> {
|
|
193
|
+
const { db, entity, typeSlug, typeId: explicitTypeId, records, skipTriggers } = options
|
|
194
|
+
|
|
195
|
+
if (!entity || !VALID_ENTITIES.includes(entity)) {
|
|
196
|
+
throw new Error(`Valid entity parameter is required: got ${entity}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let typeId = explicitTypeId
|
|
200
|
+
if (!typeId && typeSlug) {
|
|
201
|
+
const kind = ENTITY_TO_KIND[entity]
|
|
202
|
+
if (!kind) {
|
|
203
|
+
throw new Error(`type_slug is not supported for entity: ${entity}`)
|
|
204
|
+
}
|
|
205
|
+
typeId = await resolveTypeId(kind, typeSlug)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!typeId) {
|
|
209
|
+
throw new Error('type_id or type_slug is required — every runtime record must reference a type')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { data: typeRecord, error: typeErr } = await adminDb
|
|
213
|
+
.from('types')
|
|
214
|
+
.select('id, design_schema, validation_schema, is_active')
|
|
215
|
+
.eq('id', typeId)
|
|
216
|
+
.single()
|
|
217
|
+
|
|
218
|
+
if (typeErr || !typeRecord) {
|
|
219
|
+
throw new Error(`type_id not found: ${typeId}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!typeRecord.is_active) {
|
|
223
|
+
throw new Error(`type_id references an inactive type: ${typeId}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let designSchema = typeRecord.design_schema || {}
|
|
227
|
+
if (!designSchema.record_permissions) {
|
|
228
|
+
designSchema = { ...PERMISSIONS_ALL, ...designSchema }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const validationSchema = typeRecord.validation_schema || {}
|
|
232
|
+
const now = new Date().toISOString()
|
|
233
|
+
|
|
234
|
+
const rows: any[] = []
|
|
235
|
+
for (const record of records) {
|
|
236
|
+
let recordAccountId = record.accountId ?? null
|
|
237
|
+
if (record.parentRef) {
|
|
238
|
+
const { data: parentRecord } = await db
|
|
239
|
+
.from(record.parentRef.table)
|
|
240
|
+
.select('account_id, design_schema')
|
|
241
|
+
.eq('id', record.parentRef.id)
|
|
242
|
+
.maybeSingle()
|
|
243
|
+
if (!parentRecord) {
|
|
244
|
+
throw new Error('Parent record not found or not accessible')
|
|
245
|
+
}
|
|
246
|
+
recordAccountId = parentRecord.account_id
|
|
247
|
+
const parentScope = parentRecord.design_schema?.scope
|
|
248
|
+
if (parentScope) {
|
|
249
|
+
designSchema = { ...designSchema, scope: parentScope }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resolvedScope: string = designSchema.scope ?? 'account'
|
|
254
|
+
if (resolvedScope === 'platform') {
|
|
255
|
+
const { data: sysAccount } = await adminDb
|
|
256
|
+
.from('accounts')
|
|
257
|
+
.select('id')
|
|
258
|
+
.eq('slug', 'spine-system')
|
|
259
|
+
.single()
|
|
260
|
+
if (sysAccount?.id) recordAccountId = sysAccount.id
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!recordAccountId) {
|
|
264
|
+
throw new Error('account_id is required — could not resolve from caller or parent')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
rows.push({
|
|
268
|
+
...record.data,
|
|
269
|
+
type_id: typeId,
|
|
270
|
+
design_schema: designSchema,
|
|
271
|
+
validation_schema: validationSchema,
|
|
272
|
+
created_by: null,
|
|
273
|
+
account_id: recordAccountId,
|
|
274
|
+
created_at: now,
|
|
275
|
+
updated_at: now
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { data, error } = await db.from(entity).insert(rows).select()
|
|
280
|
+
if (error) throw error
|
|
281
|
+
if (!data) throw new Error('Insert succeeded but no records returned')
|
|
282
|
+
|
|
283
|
+
if (!skipTriggers) {
|
|
284
|
+
for (const row of data) {
|
|
285
|
+
const entityData = { ...row, id: row.id }
|
|
286
|
+
fireCreateTriggers(entity, row.id, entityData, {
|
|
287
|
+
principal: { id: 'system', type: 'machine', accountId: row.account_id, provenance: { sourceType: 'manual', createdBy: null, invokedAt: now } } as any,
|
|
288
|
+
accountId: row.account_id,
|
|
289
|
+
db,
|
|
290
|
+
appId: null,
|
|
291
|
+
query: {},
|
|
292
|
+
path: ''
|
|
293
|
+
}).catch(console.error)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return data.map((row: any) => ({ id: row.id, record: row }))
|
|
298
|
+
}
|
|
@@ -46,6 +46,7 @@ import { createHandler } from './_shared/middleware'
|
|
|
46
46
|
import { sanitizeRecordData } from './_shared/permissions'
|
|
47
47
|
import { adminDb } from './_shared/db'
|
|
48
48
|
import { resolveTypeId } from './_shared/resolve-ids'
|
|
49
|
+
import { createRecord } from './_shared/admin-data'
|
|
49
50
|
import { fireCreateTriggers, fireUpdateTriggers, fireDeleteTriggers } from './_shared/trigger-engine'
|
|
50
51
|
// ─── CHUNK_END: ADMIN_DATA_OVERVIEW ──────────────────────────────────────────────
|
|
51
52
|
|
|
@@ -309,89 +310,22 @@ export const create = createHandler(async (ctx, body) => {
|
|
|
309
310
|
typeId = await resolveTypeId(kind, body.type_slug)
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
// type_id is required on all runtime record creation
|
|
313
|
-
if (!typeId) {
|
|
314
|
-
const e: any = new Error('type_id or type_slug is required — every runtime record must reference a type'); e.statusCode = 400; throw e
|
|
315
|
-
}
|
|
316
|
-
recordData.type_id = typeId
|
|
317
|
-
|
|
318
|
-
// Look up the type to stamp design_schema and validation_schema
|
|
319
|
-
const { data: typeRecord, error: typeErr } = await adminDb
|
|
320
|
-
.from('types')
|
|
321
|
-
.select('id, design_schema, validation_schema, is_active')
|
|
322
|
-
.eq('id', typeId)
|
|
323
|
-
.single()
|
|
324
|
-
|
|
325
|
-
if (typeErr || !typeRecord) {
|
|
326
|
-
throw new Error(`type_id not found: ${typeId}`)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (!typeRecord.is_active) {
|
|
330
|
-
throw new Error(`type_id references an inactive type: ${typeId}`)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Ensure the type has at least permissions=ALL (defensive — migration 062 guarantees this)
|
|
334
|
-
let designSchema = typeRecord.design_schema || {}
|
|
335
|
-
if (!designSchema.record_permissions) {
|
|
336
|
-
designSchema = { ...PERMISSIONS_ALL, ...designSchema }
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Resolve account_id and propagate scope via parent inheritance.
|
|
340
|
-
// Runtime records are the source of truth: the parent record's stamped scope
|
|
341
|
-
// and account_id are inherited, not re-looked up from the type table.
|
|
342
|
-
let recordAccountId = ctx.accountId
|
|
343
313
|
const parentRef = recordData.thread_id || recordData.target_id || recordData.parent_id
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Platform-scoped records belong to the system account so they are visible
|
|
362
|
-
// across all accounts. Child records already inherited the parent's scope above.
|
|
363
|
-
const resolvedScope: string = designSchema.scope ?? 'account'
|
|
364
|
-
if (resolvedScope === 'platform') {
|
|
365
|
-
const { data: sysAccount } = await adminDb
|
|
366
|
-
.from('accounts')
|
|
367
|
-
.select('id')
|
|
368
|
-
.eq('slug', 'spine-system')
|
|
369
|
-
.single()
|
|
370
|
-
if (sysAccount?.id) recordAccountId = sysAccount.id
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Add audit fields + stamped schema
|
|
374
|
-
const dataToInsert = {
|
|
375
|
-
...recordData,
|
|
376
|
-
design_schema: designSchema,
|
|
377
|
-
validation_schema: typeRecord.validation_schema || {},
|
|
378
|
-
created_by: ctx.principal?.id,
|
|
379
|
-
account_id: recordAccountId,
|
|
380
|
-
created_at: new Date().toISOString(),
|
|
381
|
-
updated_at: new Date().toISOString()
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// RLS will check if user has INSERT permission on this account
|
|
385
|
-
const { data, error: err } = await ctx.db.from(entity)
|
|
386
|
-
.insert(dataToInsert)
|
|
387
|
-
.select()
|
|
388
|
-
.single()
|
|
389
|
-
|
|
390
|
-
if (err) throw err
|
|
391
|
-
|
|
392
|
-
// Fire triggers asynchronously (don't block response)
|
|
393
|
-
const entityData = { ...dataToInsert, id: data.id }
|
|
394
|
-
fireCreateTriggers(entity, data.id, entityData, ctx).catch(console.error)
|
|
314
|
+
const parentRefObj = parentRef
|
|
315
|
+
? { id: parentRef, table: (recordData.thread_id ? 'threads' : 'items') as 'items' | 'threads' }
|
|
316
|
+
: undefined
|
|
317
|
+
|
|
318
|
+
const { record: data } = await createRecord({
|
|
319
|
+
db: ctx.db,
|
|
320
|
+
entity,
|
|
321
|
+
typeId,
|
|
322
|
+
typeSlug: body?.type_slug,
|
|
323
|
+
accountId: ctx.accountId,
|
|
324
|
+
createdBy: ctx.principal?.id,
|
|
325
|
+
data: recordData,
|
|
326
|
+
parentRef: parentRefObj,
|
|
327
|
+
skipTriggers: false
|
|
328
|
+
})
|
|
395
329
|
|
|
396
330
|
return await sanitizeRecordData(ctx, data, entity)
|
|
397
331
|
})
|