@fayz-ai/core 0.9.0 → 0.11.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 (218) hide show
  1. package/README.md +2 -2
  2. package/dist/access/index.js.map +1 -1
  3. package/dist/chunk-BEGXYHS4.js +566 -0
  4. package/dist/chunk-BEGXYHS4.js.map +1 -0
  5. package/dist/chunk-ED6NBH6U.js +348 -0
  6. package/dist/chunk-ED6NBH6U.js.map +1 -0
  7. package/dist/chunk-EWKX4VUM.js +429 -0
  8. package/dist/chunk-EWKX4VUM.js.map +1 -0
  9. package/dist/chunk-LARZWDHA.js +319 -0
  10. package/dist/chunk-LARZWDHA.js.map +1 -0
  11. package/dist/chunk-OXQGRJZT.js +125 -0
  12. package/dist/chunk-OXQGRJZT.js.map +1 -0
  13. package/dist/chunk-RRYFXUQX.js +1247 -0
  14. package/dist/chunk-RRYFXUQX.js.map +1 -0
  15. package/dist/{chunk-5RJVUC6J.js → chunk-TDI6F6PT.js} +373 -25
  16. package/dist/chunk-TDI6F6PT.js.map +1 -0
  17. package/dist/{chunk-V2MSAUHO.js → chunk-UVKH6RDX.js} +211 -335
  18. package/dist/chunk-UVKH6RDX.js.map +1 -0
  19. package/dist/chunk-V53GR4UT.js +289 -0
  20. package/dist/chunk-V53GR4UT.js.map +1 -0
  21. package/dist/connector-runtime/index.d.ts +15 -0
  22. package/dist/connector-runtime/index.d.ts.map +1 -0
  23. package/dist/connector-runtime/index.js +3 -0
  24. package/dist/connector-runtime/index.js.map +1 -0
  25. package/dist/data/archetype.d.ts.map +1 -1
  26. package/dist/data/backend-env.d.ts +38 -0
  27. package/dist/data/backend-env.d.ts.map +1 -0
  28. package/dist/data/bulk.d.ts +24 -0
  29. package/dist/data/bulk.d.ts.map +1 -1
  30. package/dist/data/cached.d.ts.map +1 -1
  31. package/dist/data/custom-fields.d.ts +5 -0
  32. package/dist/data/custom-fields.d.ts.map +1 -0
  33. package/dist/data/index.d.ts +2 -1
  34. package/dist/data/index.d.ts.map +1 -1
  35. package/dist/data/index.js +3 -1
  36. package/dist/data/mock.d.ts.map +1 -1
  37. package/dist/data/platform-api.d.ts.map +1 -1
  38. package/dist/data/resolve.d.ts.map +1 -1
  39. package/dist/data/supabase.d.ts.map +1 -1
  40. package/dist/data/types.d.ts +8 -0
  41. package/dist/data/types.d.ts.map +1 -1
  42. package/dist/entity/events.d.ts +14 -0
  43. package/dist/entity/events.d.ts.map +1 -0
  44. package/dist/events/envelope.d.ts +31 -0
  45. package/dist/events/envelope.d.ts.map +1 -0
  46. package/dist/events/index.d.ts +4 -0
  47. package/dist/events/index.d.ts.map +1 -1
  48. package/dist/events/log.d.ts +44 -0
  49. package/dist/events/log.d.ts.map +1 -0
  50. package/dist/events/types.d.ts +40 -0
  51. package/dist/events/types.d.ts.map +1 -0
  52. package/dist/fields/store.d.ts +45 -0
  53. package/dist/fields/store.d.ts.map +1 -0
  54. package/dist/i18n/index.d.ts +10 -0
  55. package/dist/i18n/index.d.ts.map +1 -1
  56. package/dist/i18n/index.js +1 -1
  57. package/dist/i18n/shell-translations.en.d.ts.map +1 -1
  58. package/dist/i18n/shell-translations.pt-br.d.ts.map +1 -1
  59. package/dist/index.d.ts +45 -21
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +543 -56
  62. package/dist/index.js.map +1 -1
  63. package/dist/integrations/events.d.ts +7 -0
  64. package/dist/integrations/events.d.ts.map +1 -0
  65. package/dist/integrations/index.d.ts +533 -29
  66. package/dist/integrations/index.d.ts.map +1 -1
  67. package/dist/integrations/index.js +3 -1
  68. package/dist/integrations/permissions.d.ts +132 -0
  69. package/dist/integrations/permissions.d.ts.map +1 -0
  70. package/dist/integrations/request.d.ts +170 -0
  71. package/dist/integrations/request.d.ts.map +1 -0
  72. package/dist/integrations/runtime-token.d.ts +33 -0
  73. package/dist/integrations/runtime-token.d.ts.map +1 -0
  74. package/dist/integrations/runtime.d.ts +398 -0
  75. package/dist/integrations/runtime.d.ts.map +1 -0
  76. package/dist/integrations/settings.d.ts +102 -0
  77. package/dist/integrations/settings.d.ts.map +1 -0
  78. package/dist/integrations/store.d.ts +145 -0
  79. package/dist/integrations/store.d.ts.map +1 -0
  80. package/dist/{address/index.d.ts → lib/address.d.ts} +1 -1
  81. package/dist/{address/index.d.ts.map → lib/address.d.ts.map} +1 -1
  82. package/dist/{phone/index.d.ts → lib/phone.d.ts} +1 -1
  83. package/dist/lib/phone.d.ts.map +1 -0
  84. package/dist/{seo/index.d.ts → lib/seo.d.ts} +18 -1
  85. package/dist/lib/seo.d.ts.map +1 -0
  86. package/dist/{tenant/index.d.ts → lib/tenant.d.ts} +1 -1
  87. package/dist/lib/tenant.d.ts.map +1 -0
  88. package/dist/manifest/index.d.ts +1 -1
  89. package/dist/manifest/index.d.ts.map +1 -1
  90. package/dist/messaging/index.d.ts +3 -0
  91. package/dist/messaging/index.d.ts.map +1 -0
  92. package/dist/messaging/registry.d.ts +35 -0
  93. package/dist/messaging/registry.d.ts.map +1 -0
  94. package/dist/messaging/types.d.ts +67 -0
  95. package/dist/messaging/types.d.ts.map +1 -0
  96. package/dist/plugin/index.js +3 -1
  97. package/dist/plugin/runtime.d.ts +17 -0
  98. package/dist/plugin/runtime.d.ts.map +1 -1
  99. package/dist/registry/block-catalog.d.ts +78 -0
  100. package/dist/registry/block-catalog.d.ts.map +1 -0
  101. package/dist/registry/block-constraints.d.ts +64 -0
  102. package/dist/registry/block-constraints.d.ts.map +1 -0
  103. package/dist/registry/block-schema.d.ts +69 -0
  104. package/dist/registry/block-schema.d.ts.map +1 -0
  105. package/dist/registry/blocks.d.ts +84 -0
  106. package/dist/registry/blocks.d.ts.map +1 -0
  107. package/dist/registry/entities.d.ts +179 -0
  108. package/dist/registry/entities.d.ts.map +1 -0
  109. package/dist/registry/extension-points.d.ts +117 -0
  110. package/dist/registry/extension-points.d.ts.map +1 -0
  111. package/dist/registry/handles.d.ts +41 -0
  112. package/dist/registry/handles.d.ts.map +1 -0
  113. package/dist/registry/index.d.ts +41 -41
  114. package/dist/registry/index.d.ts.map +1 -1
  115. package/dist/registry/loaders.d.ts +55 -0
  116. package/dist/registry/loaders.d.ts.map +1 -0
  117. package/dist/registry/registry-class.d.ts +157 -0
  118. package/dist/registry/registry-class.d.ts.map +1 -0
  119. package/dist/registry/schema-check.d.ts +14 -0
  120. package/dist/registry/schema-check.d.ts.map +1 -0
  121. package/dist/router/index.d.ts +78 -1
  122. package/dist/router/index.d.ts.map +1 -1
  123. package/dist/router/index.js +1 -1
  124. package/dist/{shell-translations.pt-br-2GWRQRRP.js → shell-translations.pt-br-BM5ZFSKJ.js} +72 -3
  125. package/dist/shell-translations.pt-br-BM5ZFSKJ.js.map +1 -0
  126. package/dist/telemetry/errors.d.ts +87 -0
  127. package/dist/telemetry/errors.d.ts.map +1 -0
  128. package/dist/testing/index.js +4 -1
  129. package/dist/{testing/index.d.ts → testing.d.ts} +5 -3
  130. package/dist/testing.d.ts.map +1 -0
  131. package/dist/types/analytics.d.ts +213 -0
  132. package/dist/types/analytics.d.ts.map +1 -0
  133. package/dist/types/crud.d.ts +37 -3
  134. package/dist/types/crud.d.ts.map +1 -1
  135. package/dist/types/plugins.d.ts +138 -1
  136. package/dist/types/plugins.d.ts.map +1 -1
  137. package/package.json +9 -17
  138. package/dist/blocks/index.d.ts +0 -25
  139. package/dist/blocks/index.d.ts.map +0 -1
  140. package/dist/chunk-5RJVUC6J.js.map +0 -1
  141. package/dist/chunk-L4TLMYGC.js +0 -42
  142. package/dist/chunk-L4TLMYGC.js.map +0 -1
  143. package/dist/chunk-V2MSAUHO.js.map +0 -1
  144. package/dist/chunk-X5THHG6T.js +0 -77
  145. package/dist/chunk-X5THHG6T.js.map +0 -1
  146. package/dist/chunk-Y2C2FBQP.js +0 -35
  147. package/dist/chunk-Y2C2FBQP.js.map +0 -1
  148. package/dist/chunk-YH25Y4FW.js +0 -311
  149. package/dist/chunk-YH25Y4FW.js.map +0 -1
  150. package/dist/phone/index.d.ts.map +0 -1
  151. package/dist/seo/index.d.ts.map +0 -1
  152. package/dist/shell-translations.pt-br-2GWRQRRP.js.map +0 -1
  153. package/dist/tenant/index.d.ts.map +0 -1
  154. package/dist/testing/index.d.ts.map +0 -1
  155. package/src/access/denial.ts +0 -33
  156. package/src/access/engine.test.ts +0 -48
  157. package/src/access/engine.ts +0 -136
  158. package/src/access/index.ts +0 -14
  159. package/src/access/limits.ts +0 -18
  160. package/src/address/index.ts +0 -188
  161. package/src/app/render.tsx +0 -69
  162. package/src/blocks/index.tsx +0 -68
  163. package/src/data/archetype.ts +0 -255
  164. package/src/data/bulk.ts +0 -35
  165. package/src/data/cached.ts +0 -55
  166. package/src/data/count.test.ts +0 -152
  167. package/src/data/count.ts +0 -131
  168. package/src/data/filters.test.ts +0 -70
  169. package/src/data/filters.ts +0 -90
  170. package/src/data/index.ts +0 -11
  171. package/src/data/mock.ts +0 -90
  172. package/src/data/platform-api.ts +0 -209
  173. package/src/data/refresh.ts +0 -115
  174. package/src/data/resolve.ts +0 -126
  175. package/src/data/supabase.ts +0 -230
  176. package/src/data/types.ts +0 -50
  177. package/src/entity/index.ts +0 -2
  178. package/src/entity/registry.ts +0 -49
  179. package/src/events/index.tsx +0 -82
  180. package/src/i18n/index.ts +0 -476
  181. package/src/i18n/shell-translations.en.ts +0 -875
  182. package/src/i18n/shell-translations.pt-br.ts +0 -873
  183. package/src/index.ts +0 -217
  184. package/src/integrations/index.ts +0 -237
  185. package/src/lib/cache.ts +0 -118
  186. package/src/lib/csv.ts +0 -54
  187. package/src/lib/format.ts +0 -58
  188. package/src/manifest/app-manifest.schema.json +0 -676
  189. package/src/manifest/index.ts +0 -518
  190. package/src/manifest/serialize.ts +0 -52
  191. package/src/phone/index.ts +0 -85
  192. package/src/plugin/createSafeDataProvider.ts +0 -30
  193. package/src/plugin/index.ts +0 -1
  194. package/src/plugin/runtime.ts +0 -331
  195. package/src/registry/index.ts +0 -163
  196. package/src/router/history.ts +0 -86
  197. package/src/router/index.ts +0 -29
  198. package/src/runtime/index.ts +0 -15
  199. package/src/runtime/oauth.ts +0 -219
  200. package/src/search/engine.ts +0 -559
  201. package/src/search/index.ts +0 -25
  202. package/src/search/text.ts +0 -206
  203. package/src/search/types.ts +0 -75
  204. package/src/seo/index.ts +0 -128
  205. package/src/tenant/index.ts +0 -38
  206. package/src/testing/index.ts +0 -116
  207. package/src/types/auth.ts +0 -41
  208. package/src/types/billing.ts +0 -39
  209. package/src/types/crud.ts +0 -184
  210. package/src/types/entities.ts +0 -142
  211. package/src/types/entitlements.ts +0 -45
  212. package/src/types/i18n.ts +0 -7
  213. package/src/types/index.ts +0 -11
  214. package/src/types/org.ts +0 -137
  215. package/src/types/payment.ts +0 -44
  216. package/src/types/permissions.ts +0 -32
  217. package/src/types/plugins.ts +0 -480
  218. package/src/types/theme.ts +0 -8
@@ -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
- })
package/src/data/count.ts DELETED
@@ -1,131 +0,0 @@
1
- import { getSupabaseClientOptional } from './supabase'
2
- import { getActiveTenantId } from '../tenant'
3
-
4
- /**
5
- * Tenant-scoped row counter — the single cheap `count(*)` helper the access
6
- * engine uses to resolve quantity limits. Uses Supabase's `head:true` count so
7
- * no rows are transferred. Results are cached briefly per tenant+table+filter to
8
- * survive the burst of reads a page does (nav badge + gate + list), and can be
9
- * force-invalidated by creates via {@link invalidateCount}.
10
- *
11
- * MOCK MODE NEVER BLOCKS: with no global Supabase client the count is `0`, so
12
- * limits always read as "well under cap" — offline/demo apps stay fully usable.
13
- */
14
-
15
- export interface CountByTenantOptions {
16
- /** Optional `kind` column filter for multi-kind tables. */
17
- kind?: string
18
- /** `'month'` counts rows created since the 1st of the current month; `'total'` (default) counts all. */
19
- period?: 'month' | 'total'
20
- /** Tenant override; defaults to the active tenant (getActiveTenantId). */
21
- tenantId?: string
22
- /** Skip the short-lived cache and hit the DB (used by fresh guard checks). */
23
- fresh?: boolean
24
- }
25
-
26
- const TTL_MS = 15_000
27
-
28
- interface CacheEntry {
29
- value: number
30
- expires: number
31
- }
32
-
33
- const cache = new Map<string, CacheEntry>()
34
-
35
- function cacheKey(table: string, tenantId: string, kind: string | undefined, period: string): string {
36
- return `${tenantId}::${table}::${kind ?? ''}::${period}`
37
- }
38
-
39
- function startOfMonthISO(): string {
40
- const now = new Date()
41
- return new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
42
- }
43
-
44
- /**
45
- * How long a usage count may take before we give up on it. Failing OPEN on an
46
- * error was always the policy (see below); a request that never settles is the
47
- * same failure with worse symptoms — a caller awaiting this (the create guards)
48
- * hangs forever, which reads to the user as a dead button. Observed on a cold
49
- * boot, where the query can be issued before the auth session is ready.
50
- */
51
- const COUNT_TIMEOUT_MS = 5_000
52
-
53
- /**
54
- * Count rows in `table` for the given (or active) tenant.
55
- * Returns 0 when no Supabase client is registered (mock mode).
56
- */
57
- export async function countByTenant(table: string, options: CountByTenantOptions = {}): Promise<number> {
58
- const tenantId = options.tenantId ?? getActiveTenantId()
59
- if (!tenantId) return 0
60
-
61
- const period = options.period ?? 'total'
62
- const key = cacheKey(table, tenantId, options.kind, period)
63
-
64
- if (!options.fresh) {
65
- const hit = cache.get(key)
66
- if (hit && hit.expires > Date.now()) return hit.value
67
- }
68
-
69
- const client = getSupabaseClientOptional() as {
70
- from: (t: string) => {
71
- select: (columns: string, opts: { count: 'exact'; head: true }) => {
72
- eq: (col: string, val: unknown) => unknown
73
- }
74
- }
75
- } | null
76
-
77
- // No client → mock/offline mode: never block.
78
- if (!client) return 0
79
-
80
- let query = client.from(table).select('*', { count: 'exact', head: true }).eq('tenant_id', tenantId) as {
81
- eq: (col: string, val: unknown) => unknown
82
- gte: (col: string, val: unknown) => unknown
83
- }
84
-
85
- if (options.kind) {
86
- query = query.eq('kind', options.kind) as typeof query
87
- }
88
- if (period === 'month') {
89
- query = query.gte('created_at', startOfMonthISO()) as typeof query
90
- }
91
-
92
- // On error OR timeout, fail OPEN (return 0) — a counting failure must never
93
- // wall a tenant out of their own product, and must never hang the UI awaiting
94
- // it. Enforcement's source of truth is the DB anyway.
95
- let timer: ReturnType<typeof setTimeout> | undefined
96
- const timeout = new Promise<{ count: null; error: unknown; timedOut: true }>((resolve) => {
97
- timer = setTimeout(() => resolve({ count: null, error: 'timeout', timedOut: true }), COUNT_TIMEOUT_MS)
98
- })
99
- const settled = await Promise.race([
100
- query as unknown as Promise<{ count: number | null; error: unknown }>,
101
- timeout,
102
- ])
103
- if (timer) clearTimeout(timer)
104
-
105
- const { count, error } = settled
106
- const value = error ? 0 : count ?? 0
107
-
108
- // A timed-out count is not a fact about the tenant — caching it would make one
109
- // slow request suppress the guard for the whole TTL.
110
- if (!(settled as { timedOut?: true }).timedOut) {
111
- cache.set(key, { value, expires: Date.now() + TTL_MS })
112
- }
113
- return value
114
- }
115
-
116
- /**
117
- * Drop cached counts. Called by create handlers (via invalidateLimit) so the
118
- * next read reflects the row they just added.
119
- *
120
- * @param tenantOrKey Substring matched against cache keys (a tenant id, a table
121
- * name, or any fragment). Omit to clear the entire cache.
122
- */
123
- export function invalidateCount(tenantOrKey?: string): void {
124
- if (!tenantOrKey) {
125
- cache.clear()
126
- return
127
- }
128
- for (const key of cache.keys()) {
129
- if (key.includes(tenantOrKey)) cache.delete(key)
130
- }
131
- }