@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.
Files changed (170) hide show
  1. package/README.md +2 -2
  2. package/dist/access/index.js.map +1 -1
  3. package/dist/{chunk-L4TLMYGC.js → chunk-6RBW2Y5K.js} +3 -3
  4. package/dist/chunk-6RBW2Y5K.js.map +1 -0
  5. package/dist/chunk-732GZEUI.js +1592 -0
  6. package/dist/chunk-732GZEUI.js.map +1 -0
  7. package/dist/{chunk-X5THHG6T.js → chunk-CPMHCTX3.js} +3 -3
  8. package/dist/chunk-CPMHCTX3.js.map +1 -0
  9. package/dist/{chunk-V2MSAUHO.js → chunk-JO7Z2AOE.js} +2 -2
  10. package/dist/chunk-JO7Z2AOE.js.map +1 -0
  11. package/dist/{chunk-5RJVUC6J.js → chunk-M27JPX3K.js} +12 -3
  12. package/dist/chunk-M27JPX3K.js.map +1 -0
  13. package/dist/chunk-OXQGRJZT.js +125 -0
  14. package/dist/chunk-OXQGRJZT.js.map +1 -0
  15. package/dist/data/backend-env.d.ts +38 -0
  16. package/dist/data/backend-env.d.ts.map +1 -0
  17. package/dist/data/index.js +1 -1
  18. package/dist/entity/events.d.ts +14 -0
  19. package/dist/entity/events.d.ts.map +1 -0
  20. package/dist/events/envelope.d.ts +31 -0
  21. package/dist/events/envelope.d.ts.map +1 -0
  22. package/dist/events/index.d.ts +4 -0
  23. package/dist/events/index.d.ts.map +1 -1
  24. package/dist/events/log.d.ts +44 -0
  25. package/dist/events/log.d.ts.map +1 -0
  26. package/dist/events/types.d.ts +40 -0
  27. package/dist/events/types.d.ts.map +1 -0
  28. package/dist/i18n/index.d.ts +10 -0
  29. package/dist/i18n/index.d.ts.map +1 -1
  30. package/dist/i18n/index.js +1 -1
  31. package/dist/index.d.ts +27 -17
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +388 -52
  34. package/dist/index.js.map +1 -1
  35. package/dist/integrations/index.js +1 -1
  36. package/dist/{integrations/index.d.ts → integrations.d.ts} +2 -2
  37. package/dist/integrations.d.ts.map +1 -0
  38. package/dist/{address/index.d.ts → lib/address.d.ts} +1 -1
  39. package/dist/{address/index.d.ts.map → lib/address.d.ts.map} +1 -1
  40. package/dist/{phone/index.d.ts → lib/phone.d.ts} +1 -1
  41. package/dist/lib/phone.d.ts.map +1 -0
  42. package/dist/{seo/index.d.ts → lib/seo.d.ts} +18 -1
  43. package/dist/lib/seo.d.ts.map +1 -0
  44. package/dist/{tenant/index.d.ts → lib/tenant.d.ts} +1 -1
  45. package/dist/lib/tenant.d.ts.map +1 -0
  46. package/dist/manifest/index.d.ts +1 -1
  47. package/dist/manifest/index.d.ts.map +1 -1
  48. package/dist/messaging/index.d.ts +3 -0
  49. package/dist/messaging/index.d.ts.map +1 -0
  50. package/dist/messaging/registry.d.ts +35 -0
  51. package/dist/messaging/registry.d.ts.map +1 -0
  52. package/dist/messaging/types.d.ts +67 -0
  53. package/dist/messaging/types.d.ts.map +1 -0
  54. package/dist/plugin/index.js +2 -1
  55. package/dist/plugin/runtime.d.ts +17 -0
  56. package/dist/plugin/runtime.d.ts.map +1 -1
  57. package/dist/registry/block-catalog.d.ts +78 -0
  58. package/dist/registry/block-catalog.d.ts.map +1 -0
  59. package/dist/registry/block-constraints.d.ts +64 -0
  60. package/dist/registry/block-constraints.d.ts.map +1 -0
  61. package/dist/registry/block-schema.d.ts +69 -0
  62. package/dist/registry/block-schema.d.ts.map +1 -0
  63. package/dist/registry/blocks.d.ts +84 -0
  64. package/dist/registry/blocks.d.ts.map +1 -0
  65. package/dist/registry/entities.d.ts +153 -0
  66. package/dist/registry/entities.d.ts.map +1 -0
  67. package/dist/registry/extension-points.d.ts +117 -0
  68. package/dist/registry/extension-points.d.ts.map +1 -0
  69. package/dist/registry/handles.d.ts +41 -0
  70. package/dist/registry/handles.d.ts.map +1 -0
  71. package/dist/registry/index.d.ts +41 -41
  72. package/dist/registry/index.d.ts.map +1 -1
  73. package/dist/registry/loaders.d.ts +55 -0
  74. package/dist/registry/loaders.d.ts.map +1 -0
  75. package/dist/registry/registry-class.d.ts +157 -0
  76. package/dist/registry/registry-class.d.ts.map +1 -0
  77. package/dist/registry/schema-check.d.ts +14 -0
  78. package/dist/registry/schema-check.d.ts.map +1 -0
  79. package/dist/router/index.d.ts +78 -1
  80. package/dist/router/index.d.ts.map +1 -1
  81. package/dist/router/index.js +1 -1
  82. package/dist/testing/index.js +1 -1
  83. package/dist/{testing/index.d.ts → testing.d.ts} +3 -3
  84. package/dist/testing.d.ts.map +1 -0
  85. package/dist/types/analytics.d.ts +196 -0
  86. package/dist/types/analytics.d.ts.map +1 -0
  87. package/dist/types/crud.d.ts +22 -2
  88. package/dist/types/crud.d.ts.map +1 -1
  89. package/dist/types/plugins.d.ts +55 -1
  90. package/dist/types/plugins.d.ts.map +1 -1
  91. package/package.json +6 -18
  92. package/dist/blocks/index.d.ts +0 -25
  93. package/dist/blocks/index.d.ts.map +0 -1
  94. package/dist/chunk-5RJVUC6J.js.map +0 -1
  95. package/dist/chunk-L4TLMYGC.js.map +0 -1
  96. package/dist/chunk-V2MSAUHO.js.map +0 -1
  97. package/dist/chunk-X5THHG6T.js.map +0 -1
  98. package/dist/chunk-Y2C2FBQP.js +0 -35
  99. package/dist/chunk-Y2C2FBQP.js.map +0 -1
  100. package/dist/chunk-YH25Y4FW.js +0 -311
  101. package/dist/chunk-YH25Y4FW.js.map +0 -1
  102. package/dist/integrations/index.d.ts.map +0 -1
  103. package/dist/phone/index.d.ts.map +0 -1
  104. package/dist/seo/index.d.ts.map +0 -1
  105. package/dist/tenant/index.d.ts.map +0 -1
  106. package/dist/testing/index.d.ts.map +0 -1
  107. package/src/access/denial.ts +0 -33
  108. package/src/access/engine.test.ts +0 -48
  109. package/src/access/engine.ts +0 -136
  110. package/src/access/index.ts +0 -14
  111. package/src/access/limits.ts +0 -18
  112. package/src/address/index.ts +0 -188
  113. package/src/app/render.tsx +0 -69
  114. package/src/blocks/index.tsx +0 -68
  115. package/src/data/archetype.ts +0 -255
  116. package/src/data/bulk.ts +0 -35
  117. package/src/data/cached.ts +0 -55
  118. package/src/data/count.test.ts +0 -152
  119. package/src/data/count.ts +0 -131
  120. package/src/data/filters.test.ts +0 -70
  121. package/src/data/filters.ts +0 -90
  122. package/src/data/index.ts +0 -11
  123. package/src/data/mock.ts +0 -90
  124. package/src/data/platform-api.ts +0 -209
  125. package/src/data/refresh.ts +0 -115
  126. package/src/data/resolve.ts +0 -126
  127. package/src/data/supabase.ts +0 -230
  128. package/src/data/types.ts +0 -50
  129. package/src/entity/index.ts +0 -2
  130. package/src/entity/registry.ts +0 -49
  131. package/src/events/index.tsx +0 -82
  132. package/src/i18n/index.ts +0 -476
  133. package/src/i18n/shell-translations.en.ts +0 -875
  134. package/src/i18n/shell-translations.pt-br.ts +0 -873
  135. package/src/index.ts +0 -217
  136. package/src/integrations/index.ts +0 -237
  137. package/src/lib/cache.ts +0 -118
  138. package/src/lib/csv.ts +0 -54
  139. package/src/lib/format.ts +0 -58
  140. package/src/manifest/app-manifest.schema.json +0 -676
  141. package/src/manifest/index.ts +0 -518
  142. package/src/manifest/serialize.ts +0 -52
  143. package/src/phone/index.ts +0 -85
  144. package/src/plugin/createSafeDataProvider.ts +0 -30
  145. package/src/plugin/index.ts +0 -1
  146. package/src/plugin/runtime.ts +0 -331
  147. package/src/registry/index.ts +0 -163
  148. package/src/router/history.ts +0 -86
  149. package/src/router/index.ts +0 -29
  150. package/src/runtime/index.ts +0 -15
  151. package/src/runtime/oauth.ts +0 -219
  152. package/src/search/engine.ts +0 -559
  153. package/src/search/index.ts +0 -25
  154. package/src/search/text.ts +0 -206
  155. package/src/search/types.ts +0 -75
  156. package/src/seo/index.ts +0 -128
  157. package/src/tenant/index.ts +0 -38
  158. package/src/testing/index.ts +0 -116
  159. package/src/types/auth.ts +0 -41
  160. package/src/types/billing.ts +0 -39
  161. package/src/types/crud.ts +0 -184
  162. package/src/types/entities.ts +0 -142
  163. package/src/types/entitlements.ts +0 -45
  164. package/src/types/i18n.ts +0 -7
  165. package/src/types/index.ts +0 -11
  166. package/src/types/org.ts +0 -137
  167. package/src/types/payment.ts +0 -44
  168. package/src/types/permissions.ts +0 -32
  169. package/src/types/plugins.ts +0 -480
  170. package/src/types/theme.ts +0 -8
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- })