@fayz-ai/core 0.1.6
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/dist/app/render.d.ts +16 -0
- package/dist/app/render.d.ts.map +1 -0
- package/dist/blocks/index.d.ts +25 -0
- package/dist/blocks/index.d.ts.map +1 -0
- package/dist/chunk-2NTHNWEH.cjs +32 -0
- package/dist/chunk-2NTHNWEH.cjs.map +1 -0
- package/dist/chunk-5EAZOMVP.js +1609 -0
- package/dist/chunk-5EAZOMVP.js.map +1 -0
- package/dist/chunk-5TKIZVVJ.cjs +16 -0
- package/dist/chunk-5TKIZVVJ.cjs.map +1 -0
- package/dist/chunk-7RTUHSKY.js +3 -0
- package/dist/chunk-7RTUHSKY.js.map +1 -0
- package/dist/chunk-AXTWKF5X.cjs +38 -0
- package/dist/chunk-AXTWKF5X.cjs.map +1 -0
- package/dist/chunk-EGWWQ7AT.cjs +355 -0
- package/dist/chunk-EGWWQ7AT.cjs.map +1 -0
- package/dist/chunk-KAYQFSR6.js +298 -0
- package/dist/chunk-KAYQFSR6.js.map +1 -0
- package/dist/chunk-LSVBDYFQ.cjs +1639 -0
- package/dist/chunk-LSVBDYFQ.cjs.map +1 -0
- package/dist/chunk-SNQW6TRF.cjs +658 -0
- package/dist/chunk-SNQW6TRF.cjs.map +1 -0
- package/dist/chunk-WKZVSKEU.js +26 -0
- package/dist/chunk-WKZVSKEU.js.map +1 -0
- package/dist/chunk-Y2C2FBQP.js +35 -0
- package/dist/chunk-Y2C2FBQP.js.map +1 -0
- package/dist/chunk-YSBXGHAM.js +643 -0
- package/dist/chunk-YSBXGHAM.js.map +1 -0
- package/dist/data/archetype.d.ts +13 -0
- package/dist/data/archetype.d.ts.map +1 -0
- package/dist/data/cached.d.ts +19 -0
- package/dist/data/cached.d.ts.map +1 -0
- package/dist/data/fayz-api.d.ts +19 -0
- package/dist/data/fayz-api.d.ts.map +1 -0
- package/dist/data/index.cjs +32 -0
- package/dist/data/index.cjs.map +1 -0
- package/dist/data/index.d.ts +9 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +3 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/mock.d.ts +7 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/resolve.d.ts +22 -0
- package/dist/data/resolve.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +18 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +23 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/entity/index.cjs +28 -0
- package/dist/entity/index.cjs.map +1 -0
- package/dist/entity/index.d.ts +3 -0
- package/dist/entity/index.d.ts.map +1 -0
- package/dist/entity/index.js +3 -0
- package/dist/entity/index.js.map +1 -0
- package/dist/entity/registry.d.ts +20 -0
- package/dist/entity/registry.d.ts.map +1 -0
- package/dist/events/index.d.ts +16 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/i18n/index.cjs +44 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.ts +21 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/shell-translations.d.ts +2 -0
- package/dist/i18n/shell-translations.d.ts.map +1 -0
- package/dist/index.cjs +911 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +596 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cache.d.ts +21 -0
- package/dist/lib/cache.d.ts.map +1 -0
- package/dist/lib/csv.d.ts +14 -0
- package/dist/lib/csv.d.ts.map +1 -0
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/manifest/index.d.ts +80 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/plugin/createSafeDataProvider.d.ts +14 -0
- package/dist/plugin/createSafeDataProvider.d.ts.map +1 -0
- package/dist/plugin/index.cjs +32 -0
- package/dist/plugin/index.cjs.map +1 -0
- package/dist/plugin/index.d.ts +2 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +3 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/plugin/runtime.d.ts +31 -0
- package/dist/plugin/runtime.d.ts.map +1 -0
- package/dist/registry/index.d.ts +88 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/router/index.cjs +16 -0
- package/dist/router/index.cjs.map +1 -0
- package/dist/router/index.d.ts +8 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +3 -0
- package/dist/router/index.js.map +1 -0
- package/dist/runtime/index.cjs +16 -0
- package/dist/runtime/index.cjs.map +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/oauth.d.ts +96 -0
- package/dist/runtime/oauth.d.ts.map +1 -0
- package/dist/tenant/index.d.ts +3 -0
- package/dist/tenant/index.d.ts.map +1 -0
- package/dist/types/auth.d.ts +27 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/billing.d.ts +17 -0
- package/dist/types/billing.d.ts.map +1 -0
- package/dist/types/crud.d.ts +107 -0
- package/dist/types/crud.d.ts.map +1 -0
- package/dist/types/entities.d.ts +134 -0
- package/dist/types/entities.d.ts.map +1 -0
- package/dist/types/i18n.d.ts +8 -0
- package/dist/types/i18n.d.ts.map +1 -0
- package/dist/types/index.cjs +4 -0
- package/dist/types/index.cjs.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/org.d.ts +96 -0
- package/dist/types/org.d.ts.map +1 -0
- package/dist/types/permissions.d.ts +25 -0
- package/dist/types/permissions.d.ts.map +1 -0
- package/dist/types/plugins.d.ts +314 -0
- package/dist/types/plugins.d.ts.map +1 -0
- package/dist/types/theme.d.ts +16 -0
- package/dist/types/theme.d.ts.map +1 -0
- package/package.json +98 -0
- package/src/app/render.tsx +69 -0
- package/src/blocks/index.tsx +68 -0
- package/src/data/archetype.ts +210 -0
- package/src/data/cached.ts +55 -0
- package/src/data/fayz-api.ts +200 -0
- package/src/data/index.ts +8 -0
- package/src/data/mock.ts +84 -0
- package/src/data/resolve.ts +108 -0
- package/src/data/supabase.ts +169 -0
- package/src/data/types.ts +20 -0
- package/src/entity/index.ts +2 -0
- package/src/entity/registry.ts +42 -0
- package/src/events/index.tsx +82 -0
- package/src/i18n/index.ts +250 -0
- package/src/i18n/shell-translations.ts +1391 -0
- package/src/index.ts +132 -0
- package/src/lib/cache.ts +118 -0
- package/src/lib/csv.ts +54 -0
- package/src/lib/format.ts +58 -0
- package/src/manifest/app-manifest.schema.json +103 -0
- package/src/manifest/index.ts +394 -0
- package/src/plugin/createSafeDataProvider.ts +30 -0
- package/src/plugin/index.ts +1 -0
- package/src/plugin/runtime.ts +313 -0
- package/src/registry/index.ts +163 -0
- package/src/router/index.ts +29 -0
- package/src/runtime/index.ts +15 -0
- package/src/runtime/oauth.ts +219 -0
- package/src/tenant/index.ts +18 -0
- package/src/types/auth.ts +26 -0
- package/src/types/billing.ts +17 -0
- package/src/types/crud.ts +108 -0
- package/src/types/entities.ts +142 -0
- package/src/types/i18n.ts +7 -0
- package/src/types/index.ts +10 -0
- package/src/types/org.ts +94 -0
- package/src/types/permissions.ts +32 -0
- package/src/types/plugins.ts +363 -0
- package/src/types/theme.ts +17 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { DataProvider, CrudQuery, CrudResult } from './types'
|
|
2
|
+
import { getSupabaseClientOptional } from './supabase'
|
|
3
|
+
import type { EntityArchetype } from '../types/entities'
|
|
4
|
+
|
|
5
|
+
// Archetypes that have a `kind` discriminator column
|
|
6
|
+
const HAS_KIND = new Set<EntityArchetype>(['person', 'category', 'order', 'transaction', 'schedule', 'location'])
|
|
7
|
+
|
|
8
|
+
const ARCHETYPE_CONFIG: Record<EntityArchetype, { table: string; fkColumn: string }> = {
|
|
9
|
+
person: { table: 'persons', fkColumn: 'person_id' },
|
|
10
|
+
category: { table: 'categories', fkColumn: 'category_id' },
|
|
11
|
+
product: { table: 'products', fkColumn: 'product_id' },
|
|
12
|
+
service: { table: 'services', fkColumn: 'service_id' },
|
|
13
|
+
location: { table: 'locations', fkColumn: 'location_id' },
|
|
14
|
+
order: { table: 'orders', fkColumn: 'order_id' },
|
|
15
|
+
transaction: { table: 'transactions', fkColumn: 'transaction_id' },
|
|
16
|
+
|
|
17
|
+
schedule: { table: 'schedules', fkColumn: 'schedule_id' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ARCHETYPE_COLUMNS: Record<EntityArchetype, Set<string>> = {
|
|
21
|
+
person: new Set(['name', 'email', 'phone', 'document_number', 'avatar_url', 'date_of_birth', 'address', 'city', 'state', 'country', 'postal_code', 'tags', 'is_active', 'notes', 'metadata']),
|
|
22
|
+
category: new Set(['name', 'slug', 'parent_id', 'icon', 'color', 'sort_order', 'is_active', 'metadata']),
|
|
23
|
+
product: new Set(['name', 'description', 'sku', 'price', 'cost', 'currency', 'unit', 'image_url', 'stock', 'min_stock', 'status', 'is_active', 'tags', 'metadata', 'category_id']),
|
|
24
|
+
service: new Set(['name', 'description', 'price', 'cost', 'currency', 'duration_minutes', 'image_url', 'status', 'is_active', 'tags', 'metadata', 'category_id']),
|
|
25
|
+
location: new Set(['name', 'email', 'phone', 'address', 'city', 'state', 'country', 'postal_code', 'is_headquarters', 'is_active', 'tags', 'notes', 'metadata']),
|
|
26
|
+
order: new Set(['reference_number', 'status', 'party_id', 'assignee_id', 'location_id', 'subtotal', 'discount', 'tax', 'total', 'currency', 'due_at', 'completed_at', 'notes', 'tags', 'metadata']),
|
|
27
|
+
transaction: new Set(['order_id', 'party_id', 'amount', 'currency', 'payment_method', 'reference', 'status', 'transacted_at', 'notes', 'metadata']),
|
|
28
|
+
|
|
29
|
+
schedule: new Set(['assignee_id', 'location_id', 'day_of_week', 'specific_date', 'starts_at', 'ends_at', 'is_active', 'metadata']),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function camelToSnake(str: string): string {
|
|
33
|
+
return str.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function snakeToCamel(str: string): string {
|
|
37
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mapRow<T>(row: Record<string, unknown>): T {
|
|
41
|
+
const result: Record<string, unknown> = {}
|
|
42
|
+
for (const [key, value] of Object.entries(row)) {
|
|
43
|
+
result[snakeToCamel(key)] = value
|
|
44
|
+
}
|
|
45
|
+
return result as T
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ArchetypeProviderConfig {
|
|
49
|
+
archetype: EntityArchetype
|
|
50
|
+
archetypeKind: string
|
|
51
|
+
projectTable: string
|
|
52
|
+
tenantId: () => string | undefined
|
|
53
|
+
searchColumns?: string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createArchetypeProvider<T extends { id: string }>(
|
|
57
|
+
config: ArchetypeProviderConfig,
|
|
58
|
+
): DataProvider<T> {
|
|
59
|
+
const ac = ARCHETYPE_CONFIG[config.archetype]
|
|
60
|
+
const archetypeColumns = ARCHETYPE_COLUMNS[config.archetype]
|
|
61
|
+
const { fkColumn } = ac
|
|
62
|
+
|
|
63
|
+
function getClient() {
|
|
64
|
+
const supabase = getSupabaseClientOptional() as any
|
|
65
|
+
if (!supabase) throw new Error('Supabase client not available')
|
|
66
|
+
return supabase
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function coreClient() {
|
|
70
|
+
return getClient().schema('saas_core')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function splitFields(data: Record<string, unknown>): { archetypeData: Record<string, unknown>; projectData: Record<string, unknown> } {
|
|
74
|
+
const archetypeData: Record<string, unknown> = {}
|
|
75
|
+
const projectData: Record<string, unknown> = {}
|
|
76
|
+
for (const [key, value] of Object.entries(data)) {
|
|
77
|
+
if (['id', 'createdAt', 'updatedAt', 'tenantId', fkColumn].includes(key)) continue
|
|
78
|
+
const snakeKey = camelToSnake(key)
|
|
79
|
+
if (archetypeColumns.has(snakeKey)) {
|
|
80
|
+
archetypeData[snakeKey] = value
|
|
81
|
+
} else {
|
|
82
|
+
projectData[snakeKey] = value
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { archetypeData, projectData }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// View name convention: v_{shortName} — maps project table to its view
|
|
89
|
+
const VIEW_MAP: Record<string, string> = {
|
|
90
|
+
clients: 'v_clients',
|
|
91
|
+
staff_members: 'v_staff',
|
|
92
|
+
}
|
|
93
|
+
const viewName = VIEW_MAP[config.projectTable] ?? `v_${config.projectTable}`
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
97
|
+
const tenantId = config.tenantId()
|
|
98
|
+
if (!tenantId) return { data: [], total: 0 }
|
|
99
|
+
|
|
100
|
+
// Single query via v_ view (JOINs public table with saas_core archetype)
|
|
101
|
+
let q = getClient()
|
|
102
|
+
.from(viewName)
|
|
103
|
+
.select('*', { count: 'exact' })
|
|
104
|
+
.eq('tenant_id', tenantId)
|
|
105
|
+
|
|
106
|
+
// Search
|
|
107
|
+
if (query.search && config.searchColumns && config.searchColumns.length > 0) {
|
|
108
|
+
const term = `%${query.search}%`
|
|
109
|
+
const orClauses = config.searchColumns
|
|
110
|
+
.map((col) => camelToSnake(col))
|
|
111
|
+
.map((col) => `${col}.ilike.${term}`)
|
|
112
|
+
if (orClauses.length > 0) {
|
|
113
|
+
q = q.or(orClauses.join(','))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sort
|
|
118
|
+
if (query.sortBy) {
|
|
119
|
+
q = q.order(camelToSnake(query.sortBy), { ascending: query.sortDir !== 'desc' })
|
|
120
|
+
} else {
|
|
121
|
+
q = q.order('created_at', { ascending: false })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pagination
|
|
125
|
+
const page = query.page ?? 1
|
|
126
|
+
const pageSize = query.pageSize ?? 50
|
|
127
|
+
const from = (page - 1) * pageSize
|
|
128
|
+
q = q.range(from, from + pageSize - 1)
|
|
129
|
+
|
|
130
|
+
const { data, error, count } = await q
|
|
131
|
+
if (error) throw error
|
|
132
|
+
|
|
133
|
+
const rows = (data ?? []).map((row: Record<string, unknown>) => mapRow<T>(row))
|
|
134
|
+
return { data: rows, total: count ?? 0 }
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async create(data) {
|
|
138
|
+
const tenantId = config.tenantId()
|
|
139
|
+
if (!tenantId) throw new Error('No tenant selected')
|
|
140
|
+
|
|
141
|
+
const { archetypeData, projectData } = splitFields(data as Record<string, unknown>)
|
|
142
|
+
|
|
143
|
+
// 1. Insert into archetype table
|
|
144
|
+
archetypeData.tenant_id = tenantId
|
|
145
|
+
if (HAS_KIND.has(config.archetype)) {
|
|
146
|
+
archetypeData.kind = config.archetypeKind
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { data: archetypeRow, error: archetypeError } = await coreClient()
|
|
150
|
+
.from(ac.table)
|
|
151
|
+
.insert(archetypeData)
|
|
152
|
+
.select()
|
|
153
|
+
.single()
|
|
154
|
+
|
|
155
|
+
if (archetypeError) throw archetypeError
|
|
156
|
+
const archetypeId = (archetypeRow as any).id
|
|
157
|
+
|
|
158
|
+
// 2. Insert into project table with archetype FK as PK
|
|
159
|
+
projectData[fkColumn] = archetypeId
|
|
160
|
+
projectData.tenant_id = tenantId
|
|
161
|
+
|
|
162
|
+
const { error: projectError } = await getClient()
|
|
163
|
+
.from(config.projectTable)
|
|
164
|
+
.insert(projectData)
|
|
165
|
+
|
|
166
|
+
if (projectError) {
|
|
167
|
+
await coreClient().from(ac.table).delete().eq('id', archetypeId)
|
|
168
|
+
throw projectError
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { id: archetypeId, ...archetypeRow, ...projectData } as unknown as T
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async update(id, data) {
|
|
175
|
+
const { archetypeData, projectData } = splitFields(data as Record<string, unknown>)
|
|
176
|
+
|
|
177
|
+
if (Object.keys(archetypeData).length > 0) {
|
|
178
|
+
const { error } = await coreClient()
|
|
179
|
+
.from(ac.table)
|
|
180
|
+
.update(archetypeData)
|
|
181
|
+
.eq('id', id)
|
|
182
|
+
if (error) throw error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (Object.keys(projectData).length > 0) {
|
|
186
|
+
const { error } = await getClient()
|
|
187
|
+
.from(config.projectTable)
|
|
188
|
+
.update(projectData)
|
|
189
|
+
.eq(fkColumn, id)
|
|
190
|
+
if (error) throw error
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Re-fetch
|
|
194
|
+
const { data: archetypeRow } = await coreClient().from(ac.table).select('*').eq('id', id).single()
|
|
195
|
+
const { data: projectRow } = await getClient().from(config.projectTable).select('*').eq(fkColumn, id).single()
|
|
196
|
+
|
|
197
|
+
const flat = { ...archetypeRow, ...projectRow, id }
|
|
198
|
+
delete (flat as any)[fkColumn]
|
|
199
|
+
return mapRow<T>(flat as Record<string, unknown>)
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async remove(id) {
|
|
203
|
+
const { error } = await coreClient()
|
|
204
|
+
.from(ac.table)
|
|
205
|
+
.delete()
|
|
206
|
+
.eq('id', id)
|
|
207
|
+
if (error) throw error
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DataProvider, CrudQuery, CrudResult } from './types'
|
|
2
|
+
import { globalCache, stableKey } from '../lib/cache'
|
|
3
|
+
|
|
4
|
+
export interface ProviderCacheOptions {
|
|
5
|
+
/** Table/entity name — used as cache key namespace */
|
|
6
|
+
table: string
|
|
7
|
+
/** Dynamic tenant ID resolver */
|
|
8
|
+
tenantId: () => string | undefined
|
|
9
|
+
/** Override default TTL for this entity (ms) */
|
|
10
|
+
ttl?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps a DataProvider with transparent read caching + mutation invalidation.
|
|
15
|
+
*
|
|
16
|
+
* - `list()` results are cached by query params
|
|
17
|
+
* - `create()`, `update()`, `remove()` invalidate all cached entries for the table
|
|
18
|
+
*/
|
|
19
|
+
export function withCache<T extends { id: string }>(
|
|
20
|
+
provider: DataProvider<T>,
|
|
21
|
+
options: ProviderCacheOptions,
|
|
22
|
+
): DataProvider<T> {
|
|
23
|
+
function prefix(): string {
|
|
24
|
+
return `${options.tenantId() ?? '_'}:${options.table}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
29
|
+
const key = `${prefix()}:${stableKey(query)}`
|
|
30
|
+
const cached = globalCache.get<CrudResult<T>>(key)
|
|
31
|
+
if (cached) return cached
|
|
32
|
+
|
|
33
|
+
const result = await provider.list(query)
|
|
34
|
+
globalCache.set(key, result, options.ttl)
|
|
35
|
+
return result
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async create(data) {
|
|
39
|
+
const result = await provider.create(data)
|
|
40
|
+
globalCache.invalidate(prefix())
|
|
41
|
+
return result
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async update(id, data) {
|
|
45
|
+
const result = await provider.update(id, data)
|
|
46
|
+
globalCache.invalidate(prefix())
|
|
47
|
+
return result
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async remove(id) {
|
|
51
|
+
await provider.remove(id)
|
|
52
|
+
globalCache.invalidate(prefix())
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { CrudQuery, CrudResult, DataProvider } from './types'
|
|
2
|
+
|
|
3
|
+
type FayzApiFilterOperator = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'like' | 'ilike' | 'is_null' | 'is_not_null'
|
|
4
|
+
|
|
5
|
+
interface FayzApiTableFilter {
|
|
6
|
+
column: string
|
|
7
|
+
operator: FayzApiFilterOperator
|
|
8
|
+
value?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FayzApiProviderConfig {
|
|
12
|
+
baseUrl?: string
|
|
13
|
+
projectId?: string
|
|
14
|
+
tenantId?: string | (() => string | undefined)
|
|
15
|
+
tenantIdColumn?: string | false
|
|
16
|
+
entityKey?: string
|
|
17
|
+
table?: string
|
|
18
|
+
schema?: string
|
|
19
|
+
idColumn?: string
|
|
20
|
+
searchColumns?: string[]
|
|
21
|
+
runtimeToken?: string | (() => string | undefined | Promise<string | undefined>)
|
|
22
|
+
headers?: () => HeadersInit | Promise<HeadersInit>
|
|
23
|
+
fetcher?: typeof fetch
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveTenantId(config?: FayzApiProviderConfig): string | undefined {
|
|
27
|
+
const tenantId = config?.tenantId
|
|
28
|
+
return typeof tenantId === 'function' ? tenantId() : tenantId
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolveRuntimeToken(config?: FayzApiProviderConfig): Promise<string | undefined> {
|
|
32
|
+
const runtimeToken = config?.runtimeToken
|
|
33
|
+
return typeof runtimeToken === 'function' ? runtimeToken() : runtimeToken
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function trimTrailingSlash(value: string): string {
|
|
37
|
+
return value.replace(/\/+$/, '')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appendQuery(
|
|
41
|
+
url: URL,
|
|
42
|
+
query: CrudQuery,
|
|
43
|
+
tenantId?: string,
|
|
44
|
+
tenantIdColumn = 'tenant_id',
|
|
45
|
+
searchColumns: string[] = [],
|
|
46
|
+
): void {
|
|
47
|
+
const filters = normalizeFilters(query.filters)
|
|
48
|
+
if (query.search && searchColumns[0]) {
|
|
49
|
+
filters.push({ column: searchColumns[0], operator: 'ilike', value: `%${query.search}%` })
|
|
50
|
+
}
|
|
51
|
+
if (query.sortBy) url.searchParams.set('sortColumn', query.sortBy)
|
|
52
|
+
if (query.sortDir) url.searchParams.set('sortDirection', query.sortDir)
|
|
53
|
+
if (query.page != null) url.searchParams.set('page', String(query.page))
|
|
54
|
+
if (query.pageSize != null) url.searchParams.set('limit', String(query.pageSize))
|
|
55
|
+
if (tenantId) filters.push({ column: tenantIdColumn, operator: 'eq', value: tenantId })
|
|
56
|
+
if (filters.length > 0) url.searchParams.set('filters', JSON.stringify(filters))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function parseResponse<T>(response: Response): Promise<T> {
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const text = await response.text().catch(() => '')
|
|
62
|
+
throw new Error(`[@fayz-ai/core] Fayz API request failed (${response.status}): ${text || response.statusText}`)
|
|
63
|
+
}
|
|
64
|
+
return response.json() as Promise<T>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFilterOperator(value: unknown): value is FayzApiFilterOperator {
|
|
68
|
+
return typeof value === 'string' && [
|
|
69
|
+
'eq',
|
|
70
|
+
'neq',
|
|
71
|
+
'gt',
|
|
72
|
+
'lt',
|
|
73
|
+
'gte',
|
|
74
|
+
'lte',
|
|
75
|
+
'like',
|
|
76
|
+
'ilike',
|
|
77
|
+
'is_null',
|
|
78
|
+
'is_not_null',
|
|
79
|
+
].includes(value)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeFilters(filters?: Record<string, unknown>): FayzApiTableFilter[] {
|
|
87
|
+
if (!filters) return []
|
|
88
|
+
return Object.entries(filters).map(([column, raw]) => {
|
|
89
|
+
if (isRecord(raw) && isFilterOperator(raw.operator)) {
|
|
90
|
+
return { column, operator: raw.operator, value: raw.value }
|
|
91
|
+
}
|
|
92
|
+
return { column, operator: 'eq', value: raw }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createFayzApiProvider<T extends { id: string }>(
|
|
97
|
+
entityOrTable: string,
|
|
98
|
+
config: FayzApiProviderConfig = {},
|
|
99
|
+
): DataProvider<T> {
|
|
100
|
+
const fetcher = config.fetcher ?? fetch
|
|
101
|
+
const baseUrl = trimTrailingSlash(config.baseUrl ?? '')
|
|
102
|
+
const projectId = config.projectId
|
|
103
|
+
const table = config.table ?? entityOrTable
|
|
104
|
+
const idColumn = config.idColumn ?? 'id'
|
|
105
|
+
|
|
106
|
+
async function headers(runtimeToken?: string): Promise<HeadersInit> {
|
|
107
|
+
return {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
...(config.headers ? await config.headers() : {}),
|
|
110
|
+
...(runtimeToken ? { Authorization: `Bearer ${runtimeToken}` } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tableRowsUrl(runtime = false): URL {
|
|
115
|
+
const prefix = baseUrl || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
|
|
116
|
+
const encodedProjectId = encodeURIComponent(projectId ?? 'current')
|
|
117
|
+
const encodedTable = encodeURIComponent(table)
|
|
118
|
+
const route = runtime
|
|
119
|
+
? `/api/v1/runtime/projects/${encodedProjectId}/database/tables/${encodedTable}/rows`
|
|
120
|
+
: `/api/projects/${encodedProjectId}/database/tables/${encodedTable}/rows`
|
|
121
|
+
const url = new URL(route, prefix)
|
|
122
|
+
if (config.schema) url.searchParams.set('schema', config.schema)
|
|
123
|
+
return url
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function withTenant(data: Record<string, unknown>, runtime = false): Record<string, unknown> {
|
|
127
|
+
if (runtime) return data
|
|
128
|
+
const tenantId = resolveTenantId(config)
|
|
129
|
+
if (!tenantId || config.tenantIdColumn === false) return data
|
|
130
|
+
return { ...data, [config.tenantIdColumn ?? 'tenant_id']: tenantId }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function primaryKeys(id: string): Record<string, unknown> {
|
|
134
|
+
return { [idColumn]: id }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function listRows(query: CrudQuery): Promise<CrudResult<T>> {
|
|
138
|
+
const runtimeToken = await resolveRuntimeToken(config)
|
|
139
|
+
const runtime = Boolean(runtimeToken)
|
|
140
|
+
const url = tableRowsUrl(runtime)
|
|
141
|
+
appendQuery(
|
|
142
|
+
url,
|
|
143
|
+
query,
|
|
144
|
+
runtime || config.tenantIdColumn === false ? undefined : resolveTenantId(config),
|
|
145
|
+
config.tenantIdColumn || undefined,
|
|
146
|
+
config.searchColumns,
|
|
147
|
+
)
|
|
148
|
+
const result = await parseResponse<{ rows: T[]; total: number }>(await fetcher(url, { headers: await headers(runtimeToken) }))
|
|
149
|
+
return { data: result.rows, total: result.total }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function assertTenantScopedRowExists(id: string, runtime = false): Promise<void> {
|
|
153
|
+
if (runtime) return
|
|
154
|
+
const tenantId = config.tenantIdColumn === false ? undefined : resolveTenantId(config)
|
|
155
|
+
if (!tenantId) return
|
|
156
|
+
|
|
157
|
+
const result = await listRows({ filters: { [idColumn]: id }, page: 1, pageSize: 1 })
|
|
158
|
+
if (result.data.length === 0) {
|
|
159
|
+
throw new Error(`[@fayz-ai/core] Row "${id}" was not found for the active tenant.`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
165
|
+
return listRows(query)
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async create(data) {
|
|
169
|
+
const runtimeToken = await resolveRuntimeToken(config)
|
|
170
|
+
const runtime = Boolean(runtimeToken)
|
|
171
|
+
return parseResponse<T>(await fetcher(tableRowsUrl(runtime), {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: await headers(runtimeToken),
|
|
174
|
+
body: JSON.stringify(withTenant(data as Record<string, unknown>, runtime)),
|
|
175
|
+
}))
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async update(id, data) {
|
|
179
|
+
const runtimeToken = await resolveRuntimeToken(config)
|
|
180
|
+
const runtime = Boolean(runtimeToken)
|
|
181
|
+
await assertTenantScopedRowExists(id, runtime)
|
|
182
|
+
return parseResponse<T>(await fetcher(tableRowsUrl(runtime), {
|
|
183
|
+
method: 'PATCH',
|
|
184
|
+
headers: await headers(runtimeToken),
|
|
185
|
+
body: JSON.stringify({ primaryKeys: primaryKeys(id), data }),
|
|
186
|
+
}))
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async remove(id) {
|
|
190
|
+
const runtimeToken = await resolveRuntimeToken(config)
|
|
191
|
+
const runtime = Boolean(runtimeToken)
|
|
192
|
+
await assertTenantScopedRowExists(id, runtime)
|
|
193
|
+
await parseResponse<{ deletedCount: number }>(await fetcher(tableRowsUrl(runtime), {
|
|
194
|
+
method: 'DELETE',
|
|
195
|
+
headers: await headers(runtimeToken),
|
|
196
|
+
body: JSON.stringify({ rows: [primaryKeys(id)] }),
|
|
197
|
+
}))
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createSupabaseProvider, setGlobalSupabaseClient, getSupabaseClientOptional } from './supabase'
|
|
2
|
+
export { createFayzApiProvider } from './fayz-api'
|
|
3
|
+
export { createMockProvider } from './mock'
|
|
4
|
+
export { resolveDataProvider } from './resolve'
|
|
5
|
+
export type { DataProvider, CrudQuery, CrudResult } from './types'
|
|
6
|
+
export type { SupabaseProviderConfig } from './supabase'
|
|
7
|
+
export type { FayzApiProviderConfig } from './fayz-api'
|
|
8
|
+
export type { ResolveDataProviderOptions } from './resolve'
|
package/src/data/mock.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { DataProvider, CrudQuery, CrudResult } from './types'
|
|
2
|
+
import type { EntityDef } from '../types/crud'
|
|
3
|
+
|
|
4
|
+
export function createMockProvider<T extends { id: string; [key: string]: unknown }>(
|
|
5
|
+
entityDefOrSearchKeys: EntityDef<T> | string[],
|
|
6
|
+
initialData: T[] = [],
|
|
7
|
+
): DataProvider<T> {
|
|
8
|
+
let items = [...initialData]
|
|
9
|
+
|
|
10
|
+
const searchableKeys = Array.isArray(entityDefOrSearchKeys)
|
|
11
|
+
? entityDefOrSearchKeys
|
|
12
|
+
: entityDefOrSearchKeys.fields.filter((f) => f.searchable).map((f) => f.key)
|
|
13
|
+
|
|
14
|
+
const defaultSort = Array.isArray(entityDefOrSearchKeys) ? undefined : entityDefOrSearchKeys.defaultSort
|
|
15
|
+
const defaultSortDir = Array.isArray(entityDefOrSearchKeys) ? 'asc' : (entityDefOrSearchKeys.defaultSortDir ?? 'asc')
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
19
|
+
let filtered = [...items]
|
|
20
|
+
|
|
21
|
+
if (query.search && searchableKeys.length > 0) {
|
|
22
|
+
const term = query.search.toLowerCase()
|
|
23
|
+
filtered = filtered.filter((item) =>
|
|
24
|
+
searchableKeys.some((key) => {
|
|
25
|
+
const val = item[key]
|
|
26
|
+
return typeof val === 'string' && val.toLowerCase().includes(term)
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Faceted / dynamic filters (equality match per key).
|
|
32
|
+
if (query.filters) {
|
|
33
|
+
for (const [key, val] of Object.entries(query.filters)) {
|
|
34
|
+
if (val == null || val === '') continue
|
|
35
|
+
filtered = filtered.filter((item) => item[key] === val)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sortBy = query.sortBy ?? defaultSort
|
|
40
|
+
const sortDir = query.sortDir ?? defaultSortDir
|
|
41
|
+
if (sortBy) {
|
|
42
|
+
filtered.sort((a, b) => {
|
|
43
|
+
const av = a[sortBy] ?? ''
|
|
44
|
+
const bv = b[sortBy] ?? ''
|
|
45
|
+
const cmp = typeof av === 'number' && typeof bv === 'number'
|
|
46
|
+
? av - bv
|
|
47
|
+
: String(av).localeCompare(String(bv))
|
|
48
|
+
return sortDir === 'desc' ? -cmp : cmp
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const total = filtered.length
|
|
53
|
+
const page = query.page ?? 1
|
|
54
|
+
const pageSize = query.pageSize ?? 50
|
|
55
|
+
const start = (page - 1) * pageSize
|
|
56
|
+
const data = filtered.slice(start, start + pageSize)
|
|
57
|
+
return { data, total }
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async create(data) {
|
|
61
|
+
const now = new Date().toISOString()
|
|
62
|
+
const item = {
|
|
63
|
+
...data,
|
|
64
|
+
id: crypto.randomUUID(),
|
|
65
|
+
tenantId: 'mock-tenant',
|
|
66
|
+
createdAt: now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
} as unknown as T
|
|
69
|
+
items.unshift(item)
|
|
70
|
+
return item
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async update(id, data) {
|
|
74
|
+
const idx = items.findIndex((i) => i.id === id)
|
|
75
|
+
if (idx === -1) throw new Error(`[@fayz-ai/core] Mock provider: item not found: ${id}`)
|
|
76
|
+
items[idx] = { ...items[idx], ...data, updatedAt: new Date().toISOString() }
|
|
77
|
+
return items[idx]!
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async remove(id) {
|
|
81
|
+
items = items.filter((i) => i.id !== id)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { EntityDef } from '../types/crud'
|
|
2
|
+
import type { BackendRef } from '../manifest'
|
|
3
|
+
import type { DataProvider } from './types'
|
|
4
|
+
import { getSupabaseClientOptional } from './supabase'
|
|
5
|
+
import { createSupabaseProvider } from './supabase'
|
|
6
|
+
import { createArchetypeProvider } from './archetype'
|
|
7
|
+
import { createMockProvider } from './mock'
|
|
8
|
+
import { createFayzApiProvider, type FayzApiProviderConfig } from './fayz-api'
|
|
9
|
+
import { withCache } from './cached'
|
|
10
|
+
import { getActiveTenantId } from '../tenant'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the best data provider for an EntityDef.
|
|
14
|
+
* Supabase → Archetype → Mock (in order of preference).
|
|
15
|
+
*
|
|
16
|
+
* Reusable by createCrudPage, PluginRegistryManager, or any component
|
|
17
|
+
* that needs a DataProvider from an EntityDef.
|
|
18
|
+
*/
|
|
19
|
+
export interface ResolveDataProviderOptions {
|
|
20
|
+
backend?: BackendRef
|
|
21
|
+
fayzApi?: Omit<FayzApiProviderConfig, 'entityKey' | 'table'>
|
|
22
|
+
customProviders?: Record<string, <T extends { id: string }>(entityDef: EntityDef<T>, mockData?: T[]) => DataProvider<T>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveDataProvider<T extends { id: string }>(
|
|
26
|
+
entityDef: EntityDef<T>,
|
|
27
|
+
mockData?: T[],
|
|
28
|
+
options: ResolveDataProviderOptions = {},
|
|
29
|
+
): DataProvider<T> {
|
|
30
|
+
const backend = options.backend
|
|
31
|
+
|
|
32
|
+
if (backend?.provider === 'mock') {
|
|
33
|
+
return createMockProvider(entityDef, mockData)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (backend?.provider === 'fayz-api') {
|
|
37
|
+
const table = entityDef.data?.table ?? entityDef.name
|
|
38
|
+
return createFayzApiProvider<T>(table, {
|
|
39
|
+
...options.fayzApi,
|
|
40
|
+
baseUrl: options.fayzApi?.baseUrl ?? backend.url,
|
|
41
|
+
projectId: options.fayzApi?.projectId ?? backend.projectRef,
|
|
42
|
+
entityKey: entityDef.name ?? table,
|
|
43
|
+
table,
|
|
44
|
+
schema: options.fayzApi?.schema ?? entityDef.data?.schema,
|
|
45
|
+
idColumn: options.fayzApi?.idColumn ?? entityDef.data?.columnMap?.id,
|
|
46
|
+
searchColumns: options.fayzApi?.searchColumns ?? entityDef.data?.searchColumns ?? entityDef.fields
|
|
47
|
+
.filter((field) => field.searchable)
|
|
48
|
+
.map((field) => field.key),
|
|
49
|
+
tenantIdColumn: entityDef.data?.tenantScoped === false
|
|
50
|
+
? false
|
|
51
|
+
: options.fayzApi?.tenantIdColumn ?? entityDef.data?.tenantIdColumn,
|
|
52
|
+
tenantId: options.fayzApi?.tenantId ?? (() => getActiveTenantId()),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (backend?.provider === 'custom') {
|
|
57
|
+
const adapterId = backend.adapterId
|
|
58
|
+
const factory = adapterId ? options.customProviders?.[adapterId] : undefined
|
|
59
|
+
if (!adapterId || !factory) {
|
|
60
|
+
throw new Error(`[@fayz-ai/core] Custom data provider "${adapterId ?? 'unknown'}" is not registered.`)
|
|
61
|
+
}
|
|
62
|
+
return factory<T>(entityDef, mockData)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const client = getSupabaseClientOptional()
|
|
66
|
+
|
|
67
|
+
if (client && entityDef.data?.table) {
|
|
68
|
+
const tenantId = () => getActiveTenantId()
|
|
69
|
+
const cacheOptions = {
|
|
70
|
+
table: entityDef.data.table,
|
|
71
|
+
tenantId,
|
|
72
|
+
ttl: entityDef.data.cacheTTL,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (entityDef.data.archetype && entityDef.data.archetypeKind && !entityDef.data.schema) {
|
|
76
|
+
return withCache(createArchetypeProvider<T>({
|
|
77
|
+
archetype: entityDef.data.archetype,
|
|
78
|
+
archetypeKind: entityDef.data.archetypeKind,
|
|
79
|
+
projectTable: entityDef.data.table,
|
|
80
|
+
tenantId,
|
|
81
|
+
searchColumns: entityDef.data.searchColumns ?? entityDef.fields
|
|
82
|
+
.filter((field) => field.searchable)
|
|
83
|
+
.map((field) => field.key),
|
|
84
|
+
}), cacheOptions)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return withCache(createSupabaseProvider<T>(entityDef.data.table, {
|
|
88
|
+
schema: entityDef.data.schema,
|
|
89
|
+
tenantId: entityDef.data.tenantScoped === false ? undefined : tenantId,
|
|
90
|
+
tenantIdColumn: entityDef.data.tenantIdColumn,
|
|
91
|
+
searchColumns: entityDef.data.searchColumns ?? entityDef.fields
|
|
92
|
+
.filter((field) => field.searchable)
|
|
93
|
+
.map((field) => field.key),
|
|
94
|
+
selectColumns: entityDef.data.selectColumns,
|
|
95
|
+
columnMap: entityDef.data.columnMap,
|
|
96
|
+
filters: entityDef.data.filters,
|
|
97
|
+
defaults: entityDef.data.defaults,
|
|
98
|
+
}), cacheOptions)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (entityDef.data?.table) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`[fayz/core] resolveDataProvider: falling back to mock for "${entityDef.name}" (table: ${entityDef.data.table}). ` +
|
|
104
|
+
`Supabase client ${client ? 'available' : 'NOT initialized'}.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return createMockProvider(entityDef, mockData)
|
|
108
|
+
}
|