spine-framework 0.3.120 → 0.3.121

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.
@@ -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
- 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
- })
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,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.120",
3
+ "version": "0.3.121",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- }