@fayz-ai/core 0.9.0 → 0.10.0
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/README.md +2 -2
- package/dist/access/index.js.map +1 -1
- package/dist/{chunk-L4TLMYGC.js → chunk-6RBW2Y5K.js} +3 -3
- package/dist/chunk-6RBW2Y5K.js.map +1 -0
- package/dist/chunk-732GZEUI.js +1592 -0
- package/dist/chunk-732GZEUI.js.map +1 -0
- package/dist/{chunk-X5THHG6T.js → chunk-CPMHCTX3.js} +3 -3
- package/dist/chunk-CPMHCTX3.js.map +1 -0
- package/dist/{chunk-V2MSAUHO.js → chunk-JO7Z2AOE.js} +2 -2
- package/dist/chunk-JO7Z2AOE.js.map +1 -0
- package/dist/{chunk-5RJVUC6J.js → chunk-M27JPX3K.js} +12 -3
- package/dist/chunk-M27JPX3K.js.map +1 -0
- package/dist/chunk-OXQGRJZT.js +125 -0
- package/dist/chunk-OXQGRJZT.js.map +1 -0
- package/dist/data/backend-env.d.ts +38 -0
- package/dist/data/backend-env.d.ts.map +1 -0
- package/dist/data/index.js +1 -1
- package/dist/entity/events.d.ts +14 -0
- package/dist/entity/events.d.ts.map +1 -0
- package/dist/events/envelope.d.ts +31 -0
- package/dist/events/envelope.d.ts.map +1 -0
- package/dist/events/index.d.ts +4 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/log.d.ts +44 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/types.d.ts +40 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/i18n/index.d.ts +10 -0
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/index.d.ts +27 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +388 -52
- package/dist/index.js.map +1 -1
- package/dist/integrations/index.js +1 -1
- package/dist/{integrations/index.d.ts → integrations.d.ts} +2 -2
- package/dist/integrations.d.ts.map +1 -0
- package/dist/{address/index.d.ts → lib/address.d.ts} +1 -1
- package/dist/{address/index.d.ts.map → lib/address.d.ts.map} +1 -1
- package/dist/{phone/index.d.ts → lib/phone.d.ts} +1 -1
- package/dist/lib/phone.d.ts.map +1 -0
- package/dist/{seo/index.d.ts → lib/seo.d.ts} +18 -1
- package/dist/lib/seo.d.ts.map +1 -0
- package/dist/{tenant/index.d.ts → lib/tenant.d.ts} +1 -1
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/manifest/index.d.ts +1 -1
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/messaging/index.d.ts +3 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/registry.d.ts +35 -0
- package/dist/messaging/registry.d.ts.map +1 -0
- package/dist/messaging/types.d.ts +67 -0
- package/dist/messaging/types.d.ts.map +1 -0
- package/dist/plugin/index.js +2 -1
- package/dist/plugin/runtime.d.ts +17 -0
- package/dist/plugin/runtime.d.ts.map +1 -1
- package/dist/registry/block-catalog.d.ts +78 -0
- package/dist/registry/block-catalog.d.ts.map +1 -0
- package/dist/registry/block-constraints.d.ts +64 -0
- package/dist/registry/block-constraints.d.ts.map +1 -0
- package/dist/registry/block-schema.d.ts +69 -0
- package/dist/registry/block-schema.d.ts.map +1 -0
- package/dist/registry/blocks.d.ts +84 -0
- package/dist/registry/blocks.d.ts.map +1 -0
- package/dist/registry/entities.d.ts +153 -0
- package/dist/registry/entities.d.ts.map +1 -0
- package/dist/registry/extension-points.d.ts +117 -0
- package/dist/registry/extension-points.d.ts.map +1 -0
- package/dist/registry/handles.d.ts +41 -0
- package/dist/registry/handles.d.ts.map +1 -0
- package/dist/registry/index.d.ts +41 -41
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/loaders.d.ts +55 -0
- package/dist/registry/loaders.d.ts.map +1 -0
- package/dist/registry/registry-class.d.ts +157 -0
- package/dist/registry/registry-class.d.ts.map +1 -0
- package/dist/registry/schema-check.d.ts +14 -0
- package/dist/registry/schema-check.d.ts.map +1 -0
- package/dist/router/index.d.ts +78 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/{testing/index.d.ts → testing.d.ts} +3 -3
- package/dist/testing.d.ts.map +1 -0
- package/dist/types/analytics.d.ts +196 -0
- package/dist/types/analytics.d.ts.map +1 -0
- package/dist/types/crud.d.ts +22 -2
- package/dist/types/crud.d.ts.map +1 -1
- package/dist/types/plugins.d.ts +55 -1
- package/dist/types/plugins.d.ts.map +1 -1
- package/package.json +6 -18
- package/dist/blocks/index.d.ts +0 -25
- package/dist/blocks/index.d.ts.map +0 -1
- package/dist/chunk-5RJVUC6J.js.map +0 -1
- package/dist/chunk-L4TLMYGC.js.map +0 -1
- package/dist/chunk-V2MSAUHO.js.map +0 -1
- package/dist/chunk-X5THHG6T.js.map +0 -1
- package/dist/chunk-Y2C2FBQP.js +0 -35
- package/dist/chunk-Y2C2FBQP.js.map +0 -1
- package/dist/chunk-YH25Y4FW.js +0 -311
- package/dist/chunk-YH25Y4FW.js.map +0 -1
- package/dist/integrations/index.d.ts.map +0 -1
- package/dist/phone/index.d.ts.map +0 -1
- package/dist/seo/index.d.ts.map +0 -1
- package/dist/tenant/index.d.ts.map +0 -1
- package/dist/testing/index.d.ts.map +0 -1
- package/src/access/denial.ts +0 -33
- package/src/access/engine.test.ts +0 -48
- package/src/access/engine.ts +0 -136
- package/src/access/index.ts +0 -14
- package/src/access/limits.ts +0 -18
- package/src/address/index.ts +0 -188
- package/src/app/render.tsx +0 -69
- package/src/blocks/index.tsx +0 -68
- package/src/data/archetype.ts +0 -255
- package/src/data/bulk.ts +0 -35
- package/src/data/cached.ts +0 -55
- package/src/data/count.test.ts +0 -152
- package/src/data/count.ts +0 -131
- package/src/data/filters.test.ts +0 -70
- package/src/data/filters.ts +0 -90
- package/src/data/index.ts +0 -11
- package/src/data/mock.ts +0 -90
- package/src/data/platform-api.ts +0 -209
- package/src/data/refresh.ts +0 -115
- package/src/data/resolve.ts +0 -126
- package/src/data/supabase.ts +0 -230
- package/src/data/types.ts +0 -50
- package/src/entity/index.ts +0 -2
- package/src/entity/registry.ts +0 -49
- package/src/events/index.tsx +0 -82
- package/src/i18n/index.ts +0 -476
- package/src/i18n/shell-translations.en.ts +0 -875
- package/src/i18n/shell-translations.pt-br.ts +0 -873
- package/src/index.ts +0 -217
- package/src/integrations/index.ts +0 -237
- package/src/lib/cache.ts +0 -118
- package/src/lib/csv.ts +0 -54
- package/src/lib/format.ts +0 -58
- package/src/manifest/app-manifest.schema.json +0 -676
- package/src/manifest/index.ts +0 -518
- package/src/manifest/serialize.ts +0 -52
- package/src/phone/index.ts +0 -85
- package/src/plugin/createSafeDataProvider.ts +0 -30
- package/src/plugin/index.ts +0 -1
- package/src/plugin/runtime.ts +0 -331
- package/src/registry/index.ts +0 -163
- package/src/router/history.ts +0 -86
- package/src/router/index.ts +0 -29
- package/src/runtime/index.ts +0 -15
- package/src/runtime/oauth.ts +0 -219
- package/src/search/engine.ts +0 -559
- package/src/search/index.ts +0 -25
- package/src/search/text.ts +0 -206
- package/src/search/types.ts +0 -75
- package/src/seo/index.ts +0 -128
- package/src/tenant/index.ts +0 -38
- package/src/testing/index.ts +0 -116
- package/src/types/auth.ts +0 -41
- package/src/types/billing.ts +0 -39
- package/src/types/crud.ts +0 -184
- package/src/types/entities.ts +0 -142
- package/src/types/entitlements.ts +0 -45
- package/src/types/i18n.ts +0 -7
- package/src/types/index.ts +0 -11
- package/src/types/org.ts +0 -137
- package/src/types/payment.ts +0 -44
- package/src/types/permissions.ts +0 -32
- package/src/types/plugins.ts +0 -480
- package/src/types/theme.ts +0 -8
package/src/blocks/index.tsx
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { getBlock } from '../registry'
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// Block tree — the universal page primitive. A page (admin or storefront) is an
|
|
6
|
-
// ordered tree of typed blocks. Block `type`s are resolved against the block
|
|
7
|
-
// registry, so a page is pure data: `{ type, props, children }`. This
|
|
8
|
-
// generalizes the storefront's HomeSection switch to every surface.
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
export interface BlockNode {
|
|
12
|
-
/** Registered block id, e.g. 'hero', 'products', 'kpi-grid', 'custom:foo'. */
|
|
13
|
-
type: string
|
|
14
|
-
/** Stable id for keys / editing; optional. */
|
|
15
|
-
id?: string
|
|
16
|
-
props?: Record<string, unknown>
|
|
17
|
-
children?: BlockNode[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** A block component receives its declared props plus the raw node (so a block
|
|
21
|
-
* can render its own children via <BlockChildren>). */
|
|
22
|
-
export type BlockComponentProps = Record<string, unknown> & {
|
|
23
|
-
node?: BlockNode
|
|
24
|
-
children?: React.ReactNode
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Rendered in place of an unresolved block type. Unknown blocks are caught
|
|
28
|
-
// ahead of production by `fayz doctor` / the conformance kit, so this visible
|
|
29
|
-
// marker is a development aid rather than a production concern.
|
|
30
|
-
function UnknownBlock({ type }: { type: string }): React.ReactElement | null {
|
|
31
|
-
return React.createElement(
|
|
32
|
-
'div',
|
|
33
|
-
{
|
|
34
|
-
style: {
|
|
35
|
-
border: '1px dashed #c026d3',
|
|
36
|
-
color: '#a21caf',
|
|
37
|
-
padding: '8px 12px',
|
|
38
|
-
font: '12px ui-monospace, monospace',
|
|
39
|
-
borderRadius: 6,
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
`Unknown block: "${type}"`,
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function BlockRenderer({ node }: { node: BlockNode }): React.ReactElement | null {
|
|
47
|
-
const Comp = getBlock(node.type)
|
|
48
|
-
const childEls = node.children?.length
|
|
49
|
-
? node.children.map((child, i) =>
|
|
50
|
-
React.createElement(BlockRenderer, { key: child.id ?? `${child.type}-${i}`, node: child }),
|
|
51
|
-
)
|
|
52
|
-
: null
|
|
53
|
-
if (!Comp) return React.createElement(UnknownBlock, { type: node.type })
|
|
54
|
-
return React.createElement(Comp, { ...(node.props ?? {}), node }, childEls)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Render an ordered list of blocks (a page body). */
|
|
58
|
-
export function renderBlocks(nodes: BlockNode[]): React.ReactNode {
|
|
59
|
-
return nodes.map((node, i) =>
|
|
60
|
-
React.createElement(BlockRenderer, { key: node.id ?? `${node.type}-${i}`, node }),
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Convenience for block components that render their own children. */
|
|
65
|
-
export function BlockChildren({ node }: { node?: BlockNode }): React.ReactNode {
|
|
66
|
-
if (!node?.children?.length) return null
|
|
67
|
-
return renderBlocks(node.children)
|
|
68
|
-
}
|
package/src/data/archetype.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import type { DataProvider, CrudQuery, CrudResult } from './types'
|
|
2
|
-
import { getSupabaseClientOptional } from './supabase'
|
|
3
|
-
import { applyRangeToQuery, isFilterObject, parseRangeFilter } from './filters'
|
|
4
|
-
import type { EntityArchetype } from '../types/entities'
|
|
5
|
-
|
|
6
|
-
// Archetypes that have a `kind` discriminator column
|
|
7
|
-
const HAS_KIND = new Set<EntityArchetype>(['person', 'category', 'order', 'transaction', 'schedule', 'location'])
|
|
8
|
-
|
|
9
|
-
const ARCHETYPE_CONFIG: Record<EntityArchetype, { table: string; fkColumn: string }> = {
|
|
10
|
-
person: { table: 'people', fkColumn: 'person_id' },
|
|
11
|
-
category: { table: 'categories', fkColumn: 'category_id' },
|
|
12
|
-
product: { table: 'products', fkColumn: 'product_id' },
|
|
13
|
-
service: { table: 'services', fkColumn: 'service_id' },
|
|
14
|
-
location: { table: 'locations', fkColumn: 'location_id' },
|
|
15
|
-
order: { table: 'orders', fkColumn: 'order_id' },
|
|
16
|
-
transaction: { table: 'transactions', fkColumn: 'transaction_id' },
|
|
17
|
-
|
|
18
|
-
schedule: { table: 'schedules', fkColumn: 'schedule_id' },
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const ARCHETYPE_COLUMNS: Record<EntityArchetype, Set<string>> = {
|
|
22
|
-
person: new Set(['name', 'email', 'phone', 'document_number', 'avatar_url', 'date_of_birth', 'address', 'city', 'state', 'country', 'postal_code', 'tags', 'is_active', 'notes', 'metadata']),
|
|
23
|
-
category: new Set(['name', 'slug', 'parent_id', 'icon', 'color', 'sort_order', 'is_active', 'metadata']),
|
|
24
|
-
product: new Set(['name', 'description', 'sku', 'price', 'cost', 'currency', 'unit', 'image_url', 'stock', 'min_stock', 'status', 'is_active', 'tags', 'metadata', 'category_id']),
|
|
25
|
-
service: new Set(['name', 'description', 'price', 'cost', 'currency', 'duration_minutes', 'image_url', 'status', 'is_active', 'tags', 'metadata', 'category_id']),
|
|
26
|
-
location: new Set(['name', 'email', 'phone', 'address', 'city', 'state', 'country', 'postal_code', 'is_headquarters', 'is_active', 'tags', 'notes', 'metadata']),
|
|
27
|
-
order: new Set(['reference_number', 'status', 'party_id', 'assignee_id', 'location_id', 'subtotal', 'discount', 'tax', 'total', 'currency', 'due_at', 'completed_at', 'notes', 'tags', 'metadata']),
|
|
28
|
-
transaction: new Set(['order_id', 'party_id', 'amount', 'currency', 'payment_method', 'reference', 'status', 'transacted_at', 'notes', 'metadata']),
|
|
29
|
-
|
|
30
|
-
schedule: new Set(['assignee_id', 'location_id', 'day_of_week', 'specific_date', 'starts_at', 'ends_at', 'is_active', 'metadata']),
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function camelToSnake(str: string): string {
|
|
34
|
-
return str.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function snakeToCamel(str: string): string {
|
|
38
|
-
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function mapRow<T>(row: Record<string, unknown>): T {
|
|
42
|
-
const result: Record<string, unknown> = {}
|
|
43
|
-
for (const [key, value] of Object.entries(row)) {
|
|
44
|
-
result[snakeToCamel(key)] = value
|
|
45
|
-
}
|
|
46
|
-
return result as T
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ArchetypeProviderConfig {
|
|
50
|
-
archetype: EntityArchetype
|
|
51
|
-
archetypeKind: string
|
|
52
|
-
projectTable: string
|
|
53
|
-
tenantId: () => string | undefined
|
|
54
|
-
searchColumns?: string[]
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function createArchetypeProvider<T extends { id: string }>(
|
|
58
|
-
config: ArchetypeProviderConfig,
|
|
59
|
-
): DataProvider<T> {
|
|
60
|
-
const ac = ARCHETYPE_CONFIG[config.archetype]
|
|
61
|
-
const archetypeColumns = ARCHETYPE_COLUMNS[config.archetype]
|
|
62
|
-
const { fkColumn } = ac
|
|
63
|
-
|
|
64
|
-
function getClient() {
|
|
65
|
-
const supabase = getSupabaseClientOptional() as any
|
|
66
|
-
if (!supabase) throw new Error('Supabase client not available')
|
|
67
|
-
return supabase
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Core v1 lives in PUBLIC — the archetype base tables and the public extension
|
|
71
|
-
// tables share one client/schema. `coreClient()` is kept as a thin alias so the
|
|
72
|
-
// core/extension split below still reads clearly at each call site.
|
|
73
|
-
function coreClient() {
|
|
74
|
-
return getClient()
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function splitFields(data: Record<string, unknown>): { archetypeData: Record<string, unknown>; projectData: Record<string, unknown> } {
|
|
78
|
-
const archetypeData: Record<string, unknown> = {}
|
|
79
|
-
const projectData: Record<string, unknown> = {}
|
|
80
|
-
for (const [key, value] of Object.entries(data)) {
|
|
81
|
-
if (['id', 'createdAt', 'updatedAt', 'tenantId', fkColumn].includes(key)) continue
|
|
82
|
-
const snakeKey = camelToSnake(key)
|
|
83
|
-
if (archetypeColumns.has(snakeKey)) {
|
|
84
|
-
archetypeData[snakeKey] = value
|
|
85
|
-
} else {
|
|
86
|
-
projectData[snakeKey] = value
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return { archetypeData, projectData }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// View name convention: v_{shortName} — maps project table to its view
|
|
93
|
-
const VIEW_MAP: Record<string, string> = {
|
|
94
|
-
clients: 'v_clients',
|
|
95
|
-
staff_members: 'v_staff',
|
|
96
|
-
}
|
|
97
|
-
const viewName = VIEW_MAP[config.projectTable] ?? `v_${config.projectTable}`
|
|
98
|
-
|
|
99
|
-
// Pure-archetype entity: the project table IS the archetype base (e.g. a
|
|
100
|
-
// customers list configured with `table: 'people'`, no separate public
|
|
101
|
-
// extension table). Read/write public.<base> directly — there is no public
|
|
102
|
-
// extension row and no v_ view to join. Extension-table entities (clients,
|
|
103
|
-
// staff_members, …) are unaffected.
|
|
104
|
-
const isPure = config.projectTable === ac.table
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
108
|
-
const tenantId = config.tenantId()
|
|
109
|
-
if (!tenantId) return { data: [], total: 0 }
|
|
110
|
-
|
|
111
|
-
// Pure archetype → read public.<base> directly (filtered by kind);
|
|
112
|
-
// otherwise the v_ view (JOINs public extension with the public archetype).
|
|
113
|
-
// 'none' skips PostgREST's count aggregate entirely — see CrudQuery.countMode.
|
|
114
|
-
const countOpts = query.countMode === 'none' ? undefined : { count: 'exact' as const }
|
|
115
|
-
let q = isPure
|
|
116
|
-
? coreClient().from(ac.table).select('*', countOpts).eq('tenant_id', tenantId)
|
|
117
|
-
: getClient().from(viewName).select('*', countOpts).eq('tenant_id', tenantId)
|
|
118
|
-
|
|
119
|
-
if (isPure && HAS_KIND.has(config.archetype)) {
|
|
120
|
-
q = q.eq('kind', config.archetypeKind)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Faceted filters — equality, or real SQL bounds for a range object.
|
|
124
|
-
// Silently dropping them made every caller post-filter one page of rows,
|
|
125
|
-
// which reads as "no records match" whenever the match sits past it.
|
|
126
|
-
if (query.filters) {
|
|
127
|
-
for (const [key, val] of Object.entries(query.filters)) {
|
|
128
|
-
if (val == null || val === '') continue
|
|
129
|
-
const col = camelToSnake(key)
|
|
130
|
-
const range = parseRangeFilter(val)
|
|
131
|
-
if (range) {
|
|
132
|
-
q = applyRangeToQuery(q, col, range)
|
|
133
|
-
continue
|
|
134
|
-
}
|
|
135
|
-
if (isFilterObject(val)) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
`[@fayz-ai/core] Filter "${key}" is an object with no recognised bound. ` +
|
|
138
|
-
'Use {from,to} / {gte,lte,gt,lt} for ranges, or a plain value for equality.',
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
q = q.eq(col, val)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Search
|
|
146
|
-
if (query.search && config.searchColumns && config.searchColumns.length > 0) {
|
|
147
|
-
const term = `%${query.search}%`
|
|
148
|
-
const orClauses = config.searchColumns
|
|
149
|
-
.map((col) => camelToSnake(col))
|
|
150
|
-
.map((col) => `${col}.ilike.${term}`)
|
|
151
|
-
if (orClauses.length > 0) {
|
|
152
|
-
q = q.or(orClauses.join(','))
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Sort
|
|
157
|
-
if (query.sortBy) {
|
|
158
|
-
q = q.order(camelToSnake(query.sortBy), { ascending: query.sortDir !== 'desc' })
|
|
159
|
-
} else {
|
|
160
|
-
q = q.order('created_at', { ascending: false })
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Pagination
|
|
164
|
-
const page = query.page ?? 1
|
|
165
|
-
const pageSize = query.pageSize ?? 50
|
|
166
|
-
const from = (page - 1) * pageSize
|
|
167
|
-
q = q.range(from, from + pageSize - 1)
|
|
168
|
-
|
|
169
|
-
const { data, error, count } = await q
|
|
170
|
-
if (error) throw error
|
|
171
|
-
|
|
172
|
-
const rows = (data ?? []).map((row: Record<string, unknown>) => mapRow<T>(row))
|
|
173
|
-
return { data: rows, total: count ?? 0 }
|
|
174
|
-
},
|
|
175
|
-
|
|
176
|
-
async create(data) {
|
|
177
|
-
const tenantId = config.tenantId()
|
|
178
|
-
if (!tenantId) throw new Error('No tenant selected')
|
|
179
|
-
|
|
180
|
-
const { archetypeData, projectData } = splitFields(data as Record<string, unknown>)
|
|
181
|
-
|
|
182
|
-
// 1. Insert into archetype table
|
|
183
|
-
archetypeData.tenant_id = tenantId
|
|
184
|
-
if (HAS_KIND.has(config.archetype)) {
|
|
185
|
-
archetypeData.kind = config.archetypeKind
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const { data: archetypeRow, error: archetypeError } = await coreClient()
|
|
189
|
-
.from(ac.table)
|
|
190
|
-
.insert(archetypeData)
|
|
191
|
-
.select()
|
|
192
|
-
.single()
|
|
193
|
-
|
|
194
|
-
if (archetypeError) throw archetypeError
|
|
195
|
-
const archetypeId = (archetypeRow as any).id
|
|
196
|
-
|
|
197
|
-
// 2. Insert into project table with archetype FK as PK.
|
|
198
|
-
// Pure-archetype entities have no extension table — skip this step.
|
|
199
|
-
if (!isPure) {
|
|
200
|
-
projectData[fkColumn] = archetypeId
|
|
201
|
-
projectData.tenant_id = tenantId
|
|
202
|
-
|
|
203
|
-
const { error: projectError } = await getClient()
|
|
204
|
-
.from(config.projectTable)
|
|
205
|
-
.insert(projectData)
|
|
206
|
-
|
|
207
|
-
if (projectError) {
|
|
208
|
-
await coreClient().from(ac.table).delete().eq('id', archetypeId)
|
|
209
|
-
throw projectError
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return { id: archetypeId, ...archetypeRow, ...(isPure ? {} : projectData) } as unknown as T
|
|
214
|
-
},
|
|
215
|
-
|
|
216
|
-
async update(id, data) {
|
|
217
|
-
const { archetypeData, projectData } = splitFields(data as Record<string, unknown>)
|
|
218
|
-
|
|
219
|
-
if (Object.keys(archetypeData).length > 0) {
|
|
220
|
-
const { error } = await coreClient()
|
|
221
|
-
.from(ac.table)
|
|
222
|
-
.update(archetypeData)
|
|
223
|
-
.eq('id', id)
|
|
224
|
-
if (error) throw error
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (!isPure && Object.keys(projectData).length > 0) {
|
|
228
|
-
const { error } = await getClient()
|
|
229
|
-
.from(config.projectTable)
|
|
230
|
-
.update(projectData)
|
|
231
|
-
.eq(fkColumn, id)
|
|
232
|
-
if (error) throw error
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Re-fetch (archetype only for pure entities; otherwise join the extension)
|
|
236
|
-
const { data: archetypeRow } = await coreClient().from(ac.table).select('*').eq('id', id).single()
|
|
237
|
-
if (isPure) {
|
|
238
|
-
return mapRow<T>({ ...archetypeRow, id } as Record<string, unknown>)
|
|
239
|
-
}
|
|
240
|
-
const { data: projectRow } = await getClient().from(config.projectTable).select('*').eq(fkColumn, id).single()
|
|
241
|
-
|
|
242
|
-
const flat = { ...archetypeRow, ...projectRow, id }
|
|
243
|
-
delete (flat as any)[fkColumn]
|
|
244
|
-
return mapRow<T>(flat as Record<string, unknown>)
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
async remove(id) {
|
|
248
|
-
const { error } = await coreClient()
|
|
249
|
-
.from(ac.table)
|
|
250
|
-
.delete()
|
|
251
|
-
.eq('id', id)
|
|
252
|
-
if (error) throw error
|
|
253
|
-
},
|
|
254
|
-
}
|
|
255
|
-
}
|
package/src/data/bulk.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { BulkWriteOptions, BulkWriteResult, DataProvider } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Write many rows through whatever the provider supports.
|
|
5
|
-
*
|
|
6
|
-
* `createMany` when it exists (one round-trip per chunk), otherwise a sequential
|
|
7
|
-
* `create` loop. Callers never branch: a mock provider and a Supabase table
|
|
8
|
-
* import the same way, only slower.
|
|
9
|
-
*
|
|
10
|
-
* Never all-or-nothing. A 900-row client list will contain a malformed phone
|
|
11
|
-
* and a duplicate e-mail, and a single transaction would throw away the other
|
|
12
|
-
* 898 — plus a statement that large risks the timeout. Failures are collected
|
|
13
|
-
* with their row index so the caller can hand them back for correction.
|
|
14
|
-
*/
|
|
15
|
-
export async function bulkCreate<T extends { id: string }>(
|
|
16
|
-
provider: DataProvider<T>,
|
|
17
|
-
rows: Array<Omit<T, 'id' | 'createdAt' | 'updatedAt' | 'tenantId'>>,
|
|
18
|
-
options?: BulkWriteOptions,
|
|
19
|
-
): Promise<BulkWriteResult> {
|
|
20
|
-
if (!rows.length) return { created: 0, failed: [] }
|
|
21
|
-
if (provider.createMany) return provider.createMany(rows, options)
|
|
22
|
-
|
|
23
|
-
let created = 0
|
|
24
|
-
const failed: Array<{ index: number; message: string }> = []
|
|
25
|
-
for (let i = 0; i < rows.length; i++) {
|
|
26
|
-
try {
|
|
27
|
-
await provider.create(rows[i])
|
|
28
|
-
created += 1
|
|
29
|
-
} catch (error) {
|
|
30
|
-
failed.push({ index: i, message: error instanceof Error ? error.message : String(error) })
|
|
31
|
-
}
|
|
32
|
-
options?.onProgress?.(i + 1, rows.length)
|
|
33
|
-
}
|
|
34
|
-
return { created, failed }
|
|
35
|
-
}
|
package/src/data/cached.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
}
|
package/src/data/count.test.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
-
import { countByTenant, invalidateCount } from './count'
|
|
3
|
-
import { setGlobalSupabaseClient } from './supabase'
|
|
4
|
-
import { setActiveTenantId } from '../tenant'
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Mock Supabase client — a thenable query builder that records .eq/.gte calls.
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
interface MockClient {
|
|
11
|
-
client: unknown
|
|
12
|
-
selectCalls: number
|
|
13
|
-
lastEq: Array<[string, unknown]>
|
|
14
|
-
lastGte: Array<[string, unknown]>
|
|
15
|
-
setCount: (n: number, error?: unknown) => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function makeClient(initialCount = 0): MockClient {
|
|
19
|
-
const state = { count: initialCount as number, error: null as unknown }
|
|
20
|
-
const record: MockClient = {
|
|
21
|
-
client: null,
|
|
22
|
-
selectCalls: 0,
|
|
23
|
-
lastEq: [],
|
|
24
|
-
lastGte: [],
|
|
25
|
-
setCount: (n, error = null) => {
|
|
26
|
-
state.count = n
|
|
27
|
-
state.error = error
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const query: Record<string, unknown> = {
|
|
32
|
-
eq: (col: string, val: unknown) => {
|
|
33
|
-
record.lastEq.push([col, val])
|
|
34
|
-
return query
|
|
35
|
-
},
|
|
36
|
-
gte: (col: string, val: unknown) => {
|
|
37
|
-
record.lastGte.push([col, val])
|
|
38
|
-
return query
|
|
39
|
-
},
|
|
40
|
-
then: (resolve: (r: { count: number; error: unknown }) => void) =>
|
|
41
|
-
resolve({ count: state.count, error: state.error }),
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
record.client = {
|
|
45
|
-
from: () => ({
|
|
46
|
-
select: (_cols: string, _opts: unknown) => {
|
|
47
|
-
record.selectCalls++
|
|
48
|
-
return query
|
|
49
|
-
},
|
|
50
|
-
}),
|
|
51
|
-
}
|
|
52
|
-
return record
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
describe('countByTenant', () => {
|
|
56
|
-
beforeEach(() => {
|
|
57
|
-
invalidateCount() // clear all cached counts
|
|
58
|
-
setGlobalSupabaseClient(null as unknown as object)
|
|
59
|
-
setActiveTenantId('tenant-1')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('returns 0 in mock mode (no supabase client) — never blocks', async () => {
|
|
63
|
-
setGlobalSupabaseClient(null as unknown as object)
|
|
64
|
-
expect(await countByTenant('clients')).toBe(0)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('returns 0 when no tenant is resolvable', async () => {
|
|
68
|
-
setActiveTenantId(undefined)
|
|
69
|
-
const m = makeClient(5)
|
|
70
|
-
setGlobalSupabaseClient(m.client as object)
|
|
71
|
-
expect(await countByTenant('clients')).toBe(0)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('counts tenant-scoped rows via head:true', async () => {
|
|
75
|
-
const m = makeClient(42)
|
|
76
|
-
setGlobalSupabaseClient(m.client as object)
|
|
77
|
-
expect(await countByTenant('clients')).toBe(42)
|
|
78
|
-
expect(m.lastEq).toContainEqual(['tenant_id', 'tenant-1'])
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('caches within the TTL (second read does not hit the DB)', async () => {
|
|
82
|
-
const m = makeClient(10)
|
|
83
|
-
setGlobalSupabaseClient(m.client as object)
|
|
84
|
-
await countByTenant('clients')
|
|
85
|
-
await countByTenant('clients')
|
|
86
|
-
expect(m.selectCalls).toBe(1)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('fresh:true bypasses the cache', async () => {
|
|
90
|
-
const m = makeClient(10)
|
|
91
|
-
setGlobalSupabaseClient(m.client as object)
|
|
92
|
-
await countByTenant('clients')
|
|
93
|
-
await countByTenant('clients', { fresh: true })
|
|
94
|
-
expect(m.selectCalls).toBe(2)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('invalidateCount(table) drops the cached value', async () => {
|
|
98
|
-
const m = makeClient(10)
|
|
99
|
-
setGlobalSupabaseClient(m.client as object)
|
|
100
|
-
await countByTenant('clients')
|
|
101
|
-
invalidateCount('clients')
|
|
102
|
-
await countByTenant('clients')
|
|
103
|
-
expect(m.selectCalls).toBe(2)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('separate keys (kind/period) are cached independently', async () => {
|
|
107
|
-
const m = makeClient(3)
|
|
108
|
-
setGlobalSupabaseClient(m.client as object)
|
|
109
|
-
await countByTenant('bookings', { period: 'month' })
|
|
110
|
-
await countByTenant('bookings', { period: 'total' })
|
|
111
|
-
expect(m.selectCalls).toBe(2)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('applies kind filter and month window', async () => {
|
|
115
|
-
const m = makeClient(7)
|
|
116
|
-
setGlobalSupabaseClient(m.client as object)
|
|
117
|
-
await countByTenant('items', { kind: 'product', period: 'month' })
|
|
118
|
-
expect(m.lastEq).toContainEqual(['kind', 'product'])
|
|
119
|
-
expect(m.lastGte.some(([col]) => col === 'created_at')).toBe(true)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('fails open (returns 0) when the query errors', async () => {
|
|
123
|
-
const m = makeClient(0)
|
|
124
|
-
m.setCount(0, new Error('boom'))
|
|
125
|
-
setGlobalSupabaseClient(m.client as object)
|
|
126
|
-
expect(await countByTenant('clients')).toBe(0)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('fails open (returns 0) instead of hanging when the query never settles', async () => {
|
|
130
|
-
// A cold-boot query can be issued before the auth session is ready and then
|
|
131
|
-
// never resolve. The guard awaiting this must not hang the UI forever.
|
|
132
|
-
const client = {
|
|
133
|
-
from: () => ({
|
|
134
|
-
select: () => ({
|
|
135
|
-
eq: () => ({
|
|
136
|
-
eq: () => neverSettles,
|
|
137
|
-
gte: () => neverSettles,
|
|
138
|
-
then: neverSettles.then.bind(neverSettles),
|
|
139
|
-
}),
|
|
140
|
-
}),
|
|
141
|
-
}),
|
|
142
|
-
}
|
|
143
|
-
const neverSettles = { then: () => {} } as unknown as Promise<never>
|
|
144
|
-
setGlobalSupabaseClient(client as object)
|
|
145
|
-
|
|
146
|
-
vi.useFakeTimers()
|
|
147
|
-
const pending = countByTenant('clients', { fresh: true })
|
|
148
|
-
await vi.advanceTimersByTimeAsync(6_000)
|
|
149
|
-
await expect(pending).resolves.toBe(0)
|
|
150
|
-
vi.useRealTimers()
|
|
151
|
-
})
|
|
152
|
-
})
|