spine-framework 0.3.120 → 0.3.122
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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cil-state
|
|
3
|
+
* @audience both
|
|
4
|
+
* @layer shared-core
|
|
5
|
+
* @stability experimental
|
|
6
|
+
*
|
|
7
|
+
* Shared state-machine helpers for the CIL ingestion pipeline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { adminDb } from './db'
|
|
11
|
+
|
|
12
|
+
export type FileStatus = 'discovered' | 'pass2_complete' | 'pass3_complete' | 'pass4_complete' | 'pass4b_complete' | 'pass5_complete'
|
|
13
|
+
|
|
14
|
+
export interface FileTreeNode {
|
|
15
|
+
id?: string | null
|
|
16
|
+
name: string
|
|
17
|
+
path: string
|
|
18
|
+
type: 'file' | 'directory'
|
|
19
|
+
extension?: string
|
|
20
|
+
language?: string
|
|
21
|
+
status?: FileStatus
|
|
22
|
+
children?: FileTreeNode[]
|
|
23
|
+
selected?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check whether a file path is inside any of the selected paths.
|
|
28
|
+
* A file is selected if its path equals a selected path, or starts with a selected directory path + '/'.
|
|
29
|
+
*/
|
|
30
|
+
export function isSelectedPath(filePath: string, selectedPaths: string[]): boolean {
|
|
31
|
+
if (selectedPaths.length === 0) return true
|
|
32
|
+
return selectedPaths.some(sp => {
|
|
33
|
+
if (filePath === sp) return true
|
|
34
|
+
if (sp === '') return true
|
|
35
|
+
return filePath.startsWith(sp + '/')
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract the file status from a cil_file item.
|
|
41
|
+
*/
|
|
42
|
+
export function getFileStatus(fileItem: any): FileStatus {
|
|
43
|
+
return fileItem.data?.status || 'discovered'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Update the status of a cil_file item.
|
|
48
|
+
*/
|
|
49
|
+
export async function updateFileStatus(ctx: any, fileItem: any, status: FileStatus, extraData: Record<string, any> = {}): Promise<void> {
|
|
50
|
+
const { create, update } = await import('../admin-data')
|
|
51
|
+
const updateCtx = { ...ctx, query: { ...(ctx.query || {}), entity: 'items', id: fileItem.id } }
|
|
52
|
+
await update(updateCtx, {
|
|
53
|
+
data: {
|
|
54
|
+
...fileItem.data,
|
|
55
|
+
...extraData,
|
|
56
|
+
status,
|
|
57
|
+
last_status_at: new Date().toISOString(),
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a nested file tree from a flat list of discovered files.
|
|
64
|
+
*/
|
|
65
|
+
export function buildFileTree(fileRecords: { file_path: string; id: string; language: string; status: FileStatus; extension: string }[]): FileTreeNode {
|
|
66
|
+
const root: FileTreeNode = { name: 'root', path: '', type: 'directory', children: [] }
|
|
67
|
+
|
|
68
|
+
for (const record of fileRecords) {
|
|
69
|
+
const parts = record.file_path.split('/')
|
|
70
|
+
let current = root
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < parts.length; i++) {
|
|
73
|
+
const part = parts[i]
|
|
74
|
+
const isFile = i === parts.length - 1
|
|
75
|
+
const currentPath = parts.slice(0, i + 1).join('/')
|
|
76
|
+
|
|
77
|
+
let child = current.children?.find(c => c.name === part)
|
|
78
|
+
if (!child) {
|
|
79
|
+
child = {
|
|
80
|
+
name: part,
|
|
81
|
+
path: currentPath,
|
|
82
|
+
type: isFile ? 'file' : 'directory',
|
|
83
|
+
children: isFile ? undefined : []
|
|
84
|
+
}
|
|
85
|
+
if (isFile) {
|
|
86
|
+
child.id = record.id
|
|
87
|
+
child.language = record.language
|
|
88
|
+
child.status = record.status
|
|
89
|
+
child.extension = record.extension
|
|
90
|
+
}
|
|
91
|
+
current.children!.push(child)
|
|
92
|
+
}
|
|
93
|
+
current = child
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return root
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Update the status of a file node in the persisted tree by its file item ID.
|
|
102
|
+
*/
|
|
103
|
+
export function updateTreeNodeStatus(tree: FileTreeNode, fileId: string, status: FileStatus): void {
|
|
104
|
+
if (tree.id === fileId) {
|
|
105
|
+
tree.status = status
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (tree.children) {
|
|
109
|
+
for (const child of tree.children) {
|
|
110
|
+
updateTreeNodeStatus(child, fileId, status)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Persist the updated tree back to the project item.
|
|
117
|
+
*/
|
|
118
|
+
export async function saveProjectTree(ctx: any, projectId: string, tree: FileTreeNode, extraData: Record<string, any> = {}): Promise<void> {
|
|
119
|
+
const { update } = await import('../admin-data')
|
|
120
|
+
const { data: projItem } = await adminDb.from('items').select('id, data').eq('id', projectId).maybeSingle()
|
|
121
|
+
if (!projItem) return
|
|
122
|
+
const updateCtx = { ...ctx, query: { ...(ctx.query || {}), entity: 'items', id: projItem.id } }
|
|
123
|
+
await update(updateCtx, {
|
|
124
|
+
data: {
|
|
125
|
+
...projItem.data,
|
|
126
|
+
file_tree: tree,
|
|
127
|
+
...extraData,
|
|
128
|
+
last_tree_update: new Date().toISOString(),
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
}
|
|
@@ -46,7 +46,6 @@ 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'
|
|
50
49
|
import { fireCreateTriggers, fireUpdateTriggers, fireDeleteTriggers } from './_shared/trigger-engine'
|
|
51
50
|
// ─── CHUNK_END: ADMIN_DATA_OVERVIEW ──────────────────────────────────────────────
|
|
52
51
|
|
|
@@ -310,22 +309,89 @@ export const create = createHandler(async (ctx, body) => {
|
|
|
310
309
|
typeId = await resolveTypeId(kind, body.type_slug)
|
|
311
310
|
}
|
|
312
311
|
|
|
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
|
|
313
343
|
const parentRef = recordData.thread_id || recordData.target_id || recordData.parent_id
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
if (parentRef) {
|
|
345
|
+
const parentTable = recordData.thread_id ? 'threads' : 'items'
|
|
346
|
+
const { data: parentRecord } = await ctx.db
|
|
347
|
+
.from(parentTable)
|
|
348
|
+
.select('account_id, design_schema')
|
|
349
|
+
.eq('id', parentRef)
|
|
350
|
+
.maybeSingle()
|
|
351
|
+
if (!parentRecord) {
|
|
352
|
+
const e: any = new Error('Parent record not found or not accessible'); e.statusCode = 403; throw e
|
|
353
|
+
}
|
|
354
|
+
recordAccountId = parentRecord.account_id
|
|
355
|
+
const parentScope = parentRecord.design_schema?.scope
|
|
356
|
+
if (parentScope) {
|
|
357
|
+
designSchema = { ...designSchema, scope: parentScope }
|
|
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)
|
|
329
395
|
|
|
330
396
|
return await sanitizeRecordData(ctx, data, entity)
|
|
331
397
|
})
|
package/package.json
CHANGED
|
@@ -1,298 +0,0 @@
|
|
|
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
|
-
}
|