@lucashw68/nsdb 1.0.0-rc.2
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/CHANGELOG.md +34 -0
- package/GET_STARTED.md +709 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/cli/index.js +83 -0
- package/helpers/args.js +22 -0
- package/helpers/config.js +142 -0
- package/helpers/generated.js +48 -0
- package/helpers/io.js +39 -0
- package/helpers/metadata.js +19 -0
- package/helpers/names.js +16 -0
- package/helpers/relations.js +101 -0
- package/helpers/shell.js +15 -0
- package/helpers/tables.js +79 -0
- package/helpers/ts.js +37 -0
- package/module.ts +151 -0
- package/nsdb.config.example.mjs +39 -0
- package/nsdb.config.example.ts +42 -0
- package/package.json +114 -0
- package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
- package/runtime/components/NsdbForm.vue +865 -0
- package/runtime/components/NsdbList.vue +961 -0
- package/runtime/composables/useNsdbProfile.ts +119 -0
- package/runtime/composables/useNsdbSchemas.ts +176 -0
- package/runtime/composables/useSupabaseApi.ts +177 -0
- package/runtime/composables/useSupabaseApiStorage.ts +337 -0
- package/runtime/composables/useSupabaseModels.ts +412 -0
- package/runtime/query.ts +126 -0
- package/runtime/stores/createDbStore.ts +439 -0
- package/runtime/stores/createSingletonDbStore.ts +67 -0
- package/runtime/utils/dataFreshness.ts +47 -0
- package/runtime/utils/storage.ts +41 -0
- package/scripts/clear.js +64 -0
- package/scripts/generate-composables.js +100 -0
- package/scripts/generate-enums.js +106 -0
- package/scripts/generate-metadata.js +165 -0
- package/scripts/generate-models.js +164 -0
- package/scripts/generate-schemas.js +443 -0
- package/scripts/generate-stores.js +90 -0
- package/scripts/generate-types.js +196 -0
- package/scripts/init.js +225 -0
- package/templates/model.template.ts +48 -0
- package/templates/schema.template.ts +13 -0
- package/templates/useNsdbModel.template.ts +9 -0
- package/types/config.ts +50 -0
- package/types/entities.ts +66 -0
- package/types/index.ts +14 -0
- package/types/list.ts +78 -0
- package/types/model.ts +57 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { computed, shallowRef, ref, watch } from 'vue'
|
|
2
|
+
import { defineStore, useSupabaseClient, useSupabaseUser } from '#imports'
|
|
3
|
+
import { useSupabaseApi } from '../composables/useSupabaseApi'
|
|
4
|
+
import { isComplexCollectionQuery, sortCollection, stableQueryKey } from '../utils/dataFreshness'
|
|
5
|
+
import type { ListOptions } from '@lucashw68/nsdb/types/list'
|
|
6
|
+
|
|
7
|
+
export type DbStoreFetchOptions = ListOptions & {
|
|
8
|
+
merge?: boolean
|
|
9
|
+
staleTimeMs?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDbStore<T extends Record<string, any>>(resource: string, options: {
|
|
13
|
+
key?: keyof T
|
|
14
|
+
orderBy?: keyof T
|
|
15
|
+
defaultSort?: 'asc' | 'desc'
|
|
16
|
+
staleTimeMs?: number
|
|
17
|
+
persist?: boolean
|
|
18
|
+
scopeToUser?: boolean
|
|
19
|
+
}) {
|
|
20
|
+
const key = options.key || 'id'
|
|
21
|
+
const orderBy = options.orderBy || key
|
|
22
|
+
const sortDir = options.defaultSort || 'desc'
|
|
23
|
+
const defaultStaleTimeMs = options.staleTimeMs ?? 30_000
|
|
24
|
+
const shouldPersist = options.persist ?? false
|
|
25
|
+
const shouldScopeToUser = options.scopeToUser ?? true
|
|
26
|
+
|
|
27
|
+
return defineStore(`db_${resource}`, () => {
|
|
28
|
+
const supabase = useSupabaseClient()
|
|
29
|
+
const supabaseUser = useSupabaseUser()
|
|
30
|
+
const api = useSupabaseApi()
|
|
31
|
+
const items = shallowRef<T[]>([])
|
|
32
|
+
const totalCount = ref<number | null>(null)
|
|
33
|
+
const loading = ref(false)
|
|
34
|
+
const error = ref<any>(null)
|
|
35
|
+
const lastFetchedAt = ref<number | null>(null)
|
|
36
|
+
const lastQueryKey = ref<string | null>(null)
|
|
37
|
+
const scopeOwnerId = ref<string | null>(null)
|
|
38
|
+
const hydrationReady = ref(!shouldPersist || !shouldScopeToUser)
|
|
39
|
+
const stale = ref(true)
|
|
40
|
+
const cachedQueries = new Map<string, { rows: T[]; count: number | null; fetchedAt: number }>()
|
|
41
|
+
const inFlightQueries = new Map<string, Promise<T[]>>()
|
|
42
|
+
const maxCachedQueries = 20
|
|
43
|
+
let hydrationValidation: Promise<void> | null = null
|
|
44
|
+
let currentQuery: ListOptions = getDefaultQuery()
|
|
45
|
+
let collectionRevision = 0
|
|
46
|
+
let fetchSequence = 0
|
|
47
|
+
let subscription: ReturnType<typeof supabase.channel> | null = null
|
|
48
|
+
let subscriptionOwnerId: string | null = null
|
|
49
|
+
let realtimeRequested = false
|
|
50
|
+
let realtimeDisconnected = false
|
|
51
|
+
|
|
52
|
+
function resetData() {
|
|
53
|
+
items.value = []
|
|
54
|
+
totalCount.value = null
|
|
55
|
+
loading.value = false
|
|
56
|
+
error.value = null
|
|
57
|
+
lastFetchedAt.value = null
|
|
58
|
+
lastQueryKey.value = null
|
|
59
|
+
stale.value = true
|
|
60
|
+
cachedQueries.clear()
|
|
61
|
+
inFlightQueries.clear()
|
|
62
|
+
collectionRevision++
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reconcileUserScope(userId: string | null) {
|
|
66
|
+
if (!shouldScopeToUser) return
|
|
67
|
+
if (scopeOwnerId.value !== userId) {
|
|
68
|
+
void unsubscribe(false)
|
|
69
|
+
resetData()
|
|
70
|
+
}
|
|
71
|
+
scopeOwnerId.value = userId
|
|
72
|
+
if (realtimeRequested && userId) queueMicrotask(() => subscribe())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reset() {
|
|
76
|
+
realtimeRequested = false
|
|
77
|
+
void unsubscribe(false)
|
|
78
|
+
resetData()
|
|
79
|
+
if (shouldScopeToUser) scopeOwnerId.value = supabaseUser.value?.id ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function quarantineHydratedState() {
|
|
83
|
+
if (!shouldPersist || !shouldScopeToUser) {
|
|
84
|
+
hydrationReady.value = true
|
|
85
|
+
return Promise.resolve()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hydrated = {
|
|
89
|
+
items: [...items.value],
|
|
90
|
+
totalCount: totalCount.value,
|
|
91
|
+
lastFetchedAt: lastFetchedAt.value,
|
|
92
|
+
lastQueryKey: lastQueryKey.value,
|
|
93
|
+
ownerId: scopeOwnerId.value,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Persisted rows must disappear synchronously, before Vue can render them.
|
|
97
|
+
resetData()
|
|
98
|
+
scopeOwnerId.value = null
|
|
99
|
+
hydrationReady.value = false
|
|
100
|
+
|
|
101
|
+
hydrationValidation = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
const { data, error: userError } = await supabase.auth.getUser()
|
|
104
|
+
const resolvedUserId = userError ? null : data?.user?.id ?? null
|
|
105
|
+
|
|
106
|
+
if (resolvedUserId && hydrated.ownerId === resolvedUserId) {
|
|
107
|
+
items.value = hydrated.items
|
|
108
|
+
totalCount.value = hydrated.totalCount
|
|
109
|
+
lastFetchedAt.value = hydrated.lastFetchedAt
|
|
110
|
+
lastQueryKey.value = hydrated.lastQueryKey
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
scopeOwnerId.value = resolvedUserId
|
|
114
|
+
} finally {
|
|
115
|
+
hydrationReady.value = true
|
|
116
|
+
hydrationValidation = null
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
|
|
120
|
+
return hydrationValidation
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitForHydrationValidation() {
|
|
124
|
+
if (hydrationValidation) await hydrationValidation
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (shouldScopeToUser) {
|
|
128
|
+
watch(
|
|
129
|
+
() => supabaseUser.value?.id ?? null,
|
|
130
|
+
(newUserId) => reconcileUserScope(newUserId),
|
|
131
|
+
{ immediate: true },
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getItemKey(item: Partial<T> | Record<string, any>) {
|
|
136
|
+
return item?.[key as string] as string | number | undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getDefaultQuery(): ListOptions {
|
|
140
|
+
return {
|
|
141
|
+
orderBy: String(orderBy),
|
|
142
|
+
orderDirection: sortDir,
|
|
143
|
+
limit: 100,
|
|
144
|
+
offset: 0,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getQuerySignature(query: ListOptions) {
|
|
149
|
+
return stableQueryKey({
|
|
150
|
+
select: query.select ?? '*',
|
|
151
|
+
where: query.where ?? null,
|
|
152
|
+
orderBy: query.orderBy ?? null,
|
|
153
|
+
orderDirection: query.orderDirection ?? null,
|
|
154
|
+
orderForeignTable: query.orderForeignTable ?? null,
|
|
155
|
+
limit: query.limit ?? null,
|
|
156
|
+
offset: query.offset ?? null,
|
|
157
|
+
search: query.search ?? null,
|
|
158
|
+
searchColumns: query.searchColumns ?? [],
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const cachedCount = computed(() => items.value.length)
|
|
163
|
+
|
|
164
|
+
const mergeItems = (newItems: T[]) => {
|
|
165
|
+
const map = new Map<string | number | undefined, T>(items.value.map(item => [getItemKey(item), item]))
|
|
166
|
+
for (const newItem of newItems) {
|
|
167
|
+
map.set(getItemKey(newItem), newItem)
|
|
168
|
+
}
|
|
169
|
+
items.value = Array.from(map.values()).filter(Boolean) as T[]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const replaceItems = (newItems: T[]) => {
|
|
173
|
+
items.value = [...newItems]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const addOrUpdate = (item: T) => {
|
|
177
|
+
const itemKey = getItemKey(item)
|
|
178
|
+
const index = items.value.findIndex(candidate => getItemKey(candidate) === itemKey)
|
|
179
|
+
if (index !== -1) {
|
|
180
|
+
const nextItems = [...items.value]
|
|
181
|
+
nextItems[index] = item
|
|
182
|
+
items.value = nextItems
|
|
183
|
+
}
|
|
184
|
+
else items.value = [item, ...items.value]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const removeLocal = (id: string | number) => {
|
|
188
|
+
items.value = items.value.filter(item => getItemKey(item) !== id)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rememberQuery(signature: string, entry: { rows: T[]; count: number | null; fetchedAt: number }) {
|
|
192
|
+
cachedQueries.delete(signature)
|
|
193
|
+
cachedQueries.set(signature, entry)
|
|
194
|
+
while (cachedQueries.size > maxCachedQueries) {
|
|
195
|
+
const oldest = cachedQueries.keys().next().value
|
|
196
|
+
if (oldest === undefined) break
|
|
197
|
+
cachedQueries.delete(oldest)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function invalidate() {
|
|
202
|
+
collectionRevision++
|
|
203
|
+
cachedQueries.clear()
|
|
204
|
+
lastQueryKey.value = null
|
|
205
|
+
stale.value = true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function markMutation() {
|
|
209
|
+
invalidate()
|
|
210
|
+
if (isComplexCollectionQuery(currentQuery)) totalCount.value = null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const getById = (id: string | number) => {
|
|
214
|
+
return items.value.find(item => getItemKey(item) === id) || null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const fetchFromSupabaseInternal = async (query: DbStoreFetchOptions = {}, bypassCache = false) => {
|
|
218
|
+
await waitForHydrationValidation()
|
|
219
|
+
reconcileUserScope(supabaseUser.value?.id ?? null)
|
|
220
|
+
const {
|
|
221
|
+
merge = false,
|
|
222
|
+
staleTimeMs = defaultStaleTimeMs,
|
|
223
|
+
...queryOptions
|
|
224
|
+
} = query
|
|
225
|
+
const finalQuery: ListOptions = {
|
|
226
|
+
...getDefaultQuery(),
|
|
227
|
+
...queryOptions,
|
|
228
|
+
}
|
|
229
|
+
const querySignature = getQuerySignature(finalQuery)
|
|
230
|
+
currentQuery = finalQuery
|
|
231
|
+
let cachedQuery = cachedQueries.get(querySignature)
|
|
232
|
+
const now = Date.now()
|
|
233
|
+
if (!cachedQuery && lastQueryKey.value === querySignature && lastFetchedAt.value != null) {
|
|
234
|
+
cachedQuery = { rows: [...items.value], count: totalCount.value, fetchedAt: lastFetchedAt.value }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!bypassCache && cachedQuery && now - cachedQuery.fetchedAt < staleTimeMs) {
|
|
238
|
+
if (merge) mergeItems(cachedQuery.rows)
|
|
239
|
+
else replaceItems(cachedQuery.rows)
|
|
240
|
+
totalCount.value = cachedQuery.count
|
|
241
|
+
lastFetchedAt.value = cachedQuery.fetchedAt
|
|
242
|
+
lastQueryKey.value = querySignature
|
|
243
|
+
stale.value = false
|
|
244
|
+
error.value = null
|
|
245
|
+
rememberQuery(querySignature, cachedQuery)
|
|
246
|
+
return cachedQuery.rows
|
|
247
|
+
}
|
|
248
|
+
const inFlightKey = `${querySignature}:${merge ? 'merge' : 'replace'}`
|
|
249
|
+
const pending = inFlightQueries.get(inFlightKey)
|
|
250
|
+
if (!bypassCache && pending) return pending
|
|
251
|
+
|
|
252
|
+
const requestId = ++fetchSequence
|
|
253
|
+
const startingRevision = collectionRevision
|
|
254
|
+
loading.value = true
|
|
255
|
+
error.value = null
|
|
256
|
+
|
|
257
|
+
const request = (async () => {
|
|
258
|
+
try {
|
|
259
|
+
const response = await api.all<T>(resource, finalQuery)
|
|
260
|
+
|
|
261
|
+
if (!response.success) {
|
|
262
|
+
error.value = response.error
|
|
263
|
+
throw response.error
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const rows = Array.isArray(response.data) ? response.data : []
|
|
267
|
+
|
|
268
|
+
if (requestId === fetchSequence && startingRevision === collectionRevision) {
|
|
269
|
+
if (merge) mergeItems(rows)
|
|
270
|
+
else replaceItems(rows)
|
|
271
|
+
totalCount.value = response.count ?? null
|
|
272
|
+
lastFetchedAt.value = Date.now()
|
|
273
|
+
lastQueryKey.value = querySignature
|
|
274
|
+
stale.value = false
|
|
275
|
+
rememberQuery(querySignature, { rows, count: response.count ?? null, fetchedAt: Date.now() })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return rows
|
|
279
|
+
} catch (fetchError) {
|
|
280
|
+
console.error(`[${resource}] fetch error`, fetchError)
|
|
281
|
+
error.value = fetchError
|
|
282
|
+
stale.value = true
|
|
283
|
+
throw fetchError
|
|
284
|
+
} finally {
|
|
285
|
+
if (requestId === fetchSequence) loading.value = false
|
|
286
|
+
inFlightQueries.delete(inFlightKey)
|
|
287
|
+
}
|
|
288
|
+
})()
|
|
289
|
+
inFlightQueries.set(inFlightKey, request)
|
|
290
|
+
return request
|
|
291
|
+
}
|
|
292
|
+
const fetchFromSupabase = (query: DbStoreFetchOptions = {}) => fetchFromSupabaseInternal(query)
|
|
293
|
+
|
|
294
|
+
const refresh = (query: DbStoreFetchOptions = {}) => fetchFromSupabaseInternal({ ...currentQuery, ...query }, true)
|
|
295
|
+
|
|
296
|
+
const create = async (payload: Partial<T>): Promise<T | null> => {
|
|
297
|
+
await waitForHydrationValidation()
|
|
298
|
+
reconcileUserScope(supabaseUser.value?.id ?? null)
|
|
299
|
+
const response = await api.create<T>(resource, payload)
|
|
300
|
+
|
|
301
|
+
if (!response.success) {
|
|
302
|
+
console.error(`[${resource}] create error`, response.error)
|
|
303
|
+
throw response.error
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const data = response.data as T
|
|
307
|
+
markMutation()
|
|
308
|
+
if (!isComplexCollectionQuery(currentQuery)) {
|
|
309
|
+
addOrUpdate(data)
|
|
310
|
+
items.value = sortCollection(items.value, currentQuery).slice(0, currentQuery.limit ?? 100)
|
|
311
|
+
if (totalCount.value != null) totalCount.value++
|
|
312
|
+
}
|
|
313
|
+
return data
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const update = async (id: string | number, payload: Partial<T>): Promise<T | null> => {
|
|
317
|
+
await waitForHydrationValidation()
|
|
318
|
+
reconcileUserScope(supabaseUser.value?.id ?? null)
|
|
319
|
+
const response = await api.update<T>(resource, id, payload, { key: String(key) })
|
|
320
|
+
|
|
321
|
+
if (!response.success) {
|
|
322
|
+
console.error(`[${resource}] update error`, response.error)
|
|
323
|
+
throw response.error
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const data = Array.isArray(response.data) ? response.data[0] : response.data
|
|
327
|
+
if (data) {
|
|
328
|
+
markMutation()
|
|
329
|
+
if (!isComplexCollectionQuery(currentQuery)) {
|
|
330
|
+
addOrUpdate(data as T)
|
|
331
|
+
items.value = sortCollection(items.value, currentQuery)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return (data as T | undefined) ?? null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const remove = async (id: string | number) => {
|
|
338
|
+
await waitForHydrationValidation()
|
|
339
|
+
reconcileUserScope(supabaseUser.value?.id ?? null)
|
|
340
|
+
const response = await api.remove(resource, id, { key: String(key) })
|
|
341
|
+
|
|
342
|
+
if (!response.success) {
|
|
343
|
+
console.error(`[${resource}] delete error`, response.error)
|
|
344
|
+
throw response.error
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const existed = !!getById(id)
|
|
348
|
+
markMutation()
|
|
349
|
+
removeLocal(id)
|
|
350
|
+
if (!isComplexCollectionQuery(currentQuery) && existed && totalCount.value != null) totalCount.value--
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const subscribe = () => {
|
|
354
|
+
realtimeRequested = true
|
|
355
|
+
if (typeof window === 'undefined' || subscription) return
|
|
356
|
+
subscriptionOwnerId = supabaseUser.value?.id ?? null
|
|
357
|
+
|
|
358
|
+
subscription = supabase
|
|
359
|
+
.channel(`public:${resource}`)
|
|
360
|
+
.on('postgres_changes', {
|
|
361
|
+
event: '*',
|
|
362
|
+
schema: 'public',
|
|
363
|
+
table: resource
|
|
364
|
+
}, (payload: any) => {
|
|
365
|
+
if (shouldScopeToUser && subscriptionOwnerId !== (supabaseUser.value?.id ?? null)) return
|
|
366
|
+
const { eventType, new: newItem, old } = payload
|
|
367
|
+
invalidate()
|
|
368
|
+
if (isComplexCollectionQuery(currentQuery)) return
|
|
369
|
+
if (eventType === 'INSERT' || eventType === 'UPDATE') {
|
|
370
|
+
addOrUpdate(newItem as T)
|
|
371
|
+
items.value = sortCollection(items.value, currentQuery)
|
|
372
|
+
} else if (eventType === 'DELETE') {
|
|
373
|
+
removeLocal(old?.[key as string])
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
.subscribe((status: string, statusError?: unknown) => {
|
|
377
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
|
378
|
+
realtimeDisconnected = true
|
|
379
|
+
stale.value = true
|
|
380
|
+
if (statusError) error.value = statusError
|
|
381
|
+
}
|
|
382
|
+
else if (status === 'SUBSCRIBED' && realtimeDisconnected) {
|
|
383
|
+
realtimeDisconnected = false
|
|
384
|
+
invalidate()
|
|
385
|
+
void refresh().catch(() => {})
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
console.info(`[${resource}] realtime subscription initialized`)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function unsubscribe(clearIntent = true) {
|
|
393
|
+
if (clearIntent) realtimeRequested = false
|
|
394
|
+
const active = subscription
|
|
395
|
+
subscription = null
|
|
396
|
+
subscriptionOwnerId = null
|
|
397
|
+
realtimeDisconnected = false
|
|
398
|
+
if (!active) return
|
|
399
|
+
if (typeof (supabase as any).removeChannel === 'function') await (supabase as any).removeChannel(active)
|
|
400
|
+
else await active.unsubscribe()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
items,
|
|
405
|
+
totalCount,
|
|
406
|
+
cachedCount,
|
|
407
|
+
loading,
|
|
408
|
+
error,
|
|
409
|
+
stale,
|
|
410
|
+
lastFetchedAt,
|
|
411
|
+
lastQueryKey,
|
|
412
|
+
scopeOwnerId,
|
|
413
|
+
hydrationReady,
|
|
414
|
+
fetchFromSupabase,
|
|
415
|
+
refresh,
|
|
416
|
+
invalidate,
|
|
417
|
+
addOrUpdate,
|
|
418
|
+
removeLocal,
|
|
419
|
+
reset,
|
|
420
|
+
remove,
|
|
421
|
+
getById,
|
|
422
|
+
create,
|
|
423
|
+
update,
|
|
424
|
+
subscribe,
|
|
425
|
+
unsubscribe,
|
|
426
|
+
reconcileUserScope,
|
|
427
|
+
quarantineHydratedState,
|
|
428
|
+
}
|
|
429
|
+
}, {
|
|
430
|
+
persist: shouldPersist
|
|
431
|
+
? {
|
|
432
|
+
pick: ['items', 'totalCount', 'lastFetchedAt', 'lastQueryKey', 'scopeOwnerId'],
|
|
433
|
+
afterHydrate: ({ store }: { store: { quarantineHydratedState?: () => Promise<void> } }) => {
|
|
434
|
+
void store.quarantineHydratedState?.()
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
: false,
|
|
438
|
+
} as any)
|
|
439
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { defineStore } from '#imports'
|
|
3
|
+
|
|
4
|
+
export interface SingletonStoreOptions<T> {
|
|
5
|
+
/** Fonction de chargement de la ressource depuis Supabase ou autre */
|
|
6
|
+
fetch: () => Promise<{ success: boolean; data?: T; error?: any }>
|
|
7
|
+
|
|
8
|
+
/** Fonction de mise à jour de la ressource dans la base */
|
|
9
|
+
update?: (partialData: Partial<T>) => Promise<{ success: boolean; data?: T; error?: any }>
|
|
10
|
+
|
|
11
|
+
/** Clé d'identification unique du store */
|
|
12
|
+
id: string
|
|
13
|
+
|
|
14
|
+
/** Activer la persistance locale */
|
|
15
|
+
persist?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSingletonStore<T>(options: SingletonStoreOptions<T>) {
|
|
19
|
+
const {
|
|
20
|
+
id,
|
|
21
|
+
fetch,
|
|
22
|
+
update = async () => ({ success: false, error: 'Update not implemented' }),
|
|
23
|
+
persist = false
|
|
24
|
+
} = options
|
|
25
|
+
|
|
26
|
+
return defineStore(id, () => {
|
|
27
|
+
const data = ref<T | null>(null)
|
|
28
|
+
const loading = ref(false)
|
|
29
|
+
const error = ref<any>(null)
|
|
30
|
+
|
|
31
|
+
const fetchData = async () => {
|
|
32
|
+
loading.value = true
|
|
33
|
+
const result = await fetch()
|
|
34
|
+
loading.value = false
|
|
35
|
+
|
|
36
|
+
if (result.success && result.data) {
|
|
37
|
+
data.value = result.data
|
|
38
|
+
error.value = null
|
|
39
|
+
} else {
|
|
40
|
+
error.value = result.error
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const updateData = async (partialData: Partial<T>) => {
|
|
47
|
+
const result = await update(partialData)
|
|
48
|
+
|
|
49
|
+
if (result.success && result.data) {
|
|
50
|
+
data.value = result.data
|
|
51
|
+
error.value = null
|
|
52
|
+
} else {
|
|
53
|
+
error.value = result.error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
data,
|
|
61
|
+
loading,
|
|
62
|
+
error,
|
|
63
|
+
fetch: fetchData,
|
|
64
|
+
update: updateData
|
|
65
|
+
}
|
|
66
|
+
}, { persist } as any)
|
|
67
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ListOptions } from '@lucashw68/nsdb/types/list'
|
|
2
|
+
|
|
3
|
+
function normalize(value: unknown): unknown {
|
|
4
|
+
if (Array.isArray(value)) return value.map(normalize)
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.entries(value as Record<string, unknown>)
|
|
8
|
+
.filter(([, entry]) => entry !== undefined)
|
|
9
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
10
|
+
.map(([key, entry]) => [key, normalize(entry)]),
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stableQueryKey(value: unknown): string {
|
|
17
|
+
return JSON.stringify(normalize(value))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isComplexCollectionQuery(query: ListOptions): boolean {
|
|
21
|
+
return !!(
|
|
22
|
+
(query.where && Object.keys(query.where).length > 0)
|
|
23
|
+
|| query.search
|
|
24
|
+
|| (query.offset ?? 0) > 0
|
|
25
|
+
|| query.orderForeignTable
|
|
26
|
+
// Selecting a subset of scalar columns does not change membership. An
|
|
27
|
+
// embedded PostgREST relation does, and cannot be maintained locally.
|
|
28
|
+
|| (query.select?.includes('(') ?? false)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sortCollection<T extends Record<string, any>>(
|
|
33
|
+
rows: T[],
|
|
34
|
+
query: ListOptions,
|
|
35
|
+
): T[] {
|
|
36
|
+
if (!query.orderBy || query.orderForeignTable) return [...rows]
|
|
37
|
+
const direction = query.orderDirection === 'desc' ? -1 : 1
|
|
38
|
+
const column = query.orderBy
|
|
39
|
+
return [...rows].sort((left, right) => {
|
|
40
|
+
const a = left[column]
|
|
41
|
+
const b = right[column]
|
|
42
|
+
if (a == null && b == null) return 0
|
|
43
|
+
if (a == null) return 1
|
|
44
|
+
if (b == null) return -1
|
|
45
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true }) * direction
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function normalizePath(path = '') {
|
|
2
|
+
const segments = path
|
|
3
|
+
.split('/')
|
|
4
|
+
.map(part => part.trim())
|
|
5
|
+
.filter(Boolean)
|
|
6
|
+
|
|
7
|
+
if (segments.some(part => part === '.' || part === '..')) {
|
|
8
|
+
throw new Error('[nsdb:storage] Relative path segments are not allowed.')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return segments.join('/')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeDirectoryPath(path = '') {
|
|
15
|
+
return normalizePath(path)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeFilePath(path: string) {
|
|
19
|
+
const normalizedPath = normalizePath(path)
|
|
20
|
+
if (!normalizedPath) throw new Error('[nsdb:storage] A file path is required.')
|
|
21
|
+
return normalizedPath
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function joinPath(...parts: Array<string | number | null | undefined>) {
|
|
25
|
+
return parts
|
|
26
|
+
.map(part => normalizePath(String(part ?? '')))
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join('/')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applySearchFilter<T extends { name?: string }>(items: T[], search?: string) {
|
|
32
|
+
const normalizedSearch = search?.trim().toLowerCase()
|
|
33
|
+
if (!normalizedSearch) return items
|
|
34
|
+
return items.filter(item => item.name?.toLowerCase().includes(normalizedSearch))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeBucketName(bucketName: string) {
|
|
38
|
+
const normalizedName = bucketName.trim()
|
|
39
|
+
if (!normalizedName) throw new Error('[nsdb:storage] A bucket name is required.')
|
|
40
|
+
return normalizedName
|
|
41
|
+
}
|
package/scripts/clear.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseArgs } from '../helpers/args.js'
|
|
4
|
+
import { loadNsdbConfig } from '../helpers/config.js'
|
|
5
|
+
import {
|
|
6
|
+
isNsdbGeneratedFile,
|
|
7
|
+
listGeneratedFiles,
|
|
8
|
+
removeGeneratedFile,
|
|
9
|
+
} from '../helpers/generated.js'
|
|
10
|
+
|
|
11
|
+
function configuredTargets(currentWorkingDirectory, config, shouldDeleteStores) {
|
|
12
|
+
const fileTargets = [config.paths.enums]
|
|
13
|
+
const directoryTargets = [
|
|
14
|
+
config.paths.schemas,
|
|
15
|
+
config.paths.models,
|
|
16
|
+
config.paths.composables,
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
if (shouldDeleteStores) directoryTargets.push(config.paths.stores)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
fileTargets: fileTargets.map(target => path.resolve(currentWorkingDirectory, target)),
|
|
23
|
+
directoryTargets: [...new Set(directoryTargets.map(target => path.resolve(currentWorkingDirectory, target)))],
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function clearGeneratedFiles({
|
|
28
|
+
currentWorkingDirectory = process.cwd(),
|
|
29
|
+
parsedArguments = parseArgs(),
|
|
30
|
+
} = {}) {
|
|
31
|
+
const verbose = parsedArguments.getBool('verbose', false)
|
|
32
|
+
const dryRun = parsedArguments.getBool('dry-run', false)
|
|
33
|
+
const shouldDeleteStores = !parsedArguments.getBool('no-stores', false)
|
|
34
|
+
const { config } = await loadNsdbConfig(
|
|
35
|
+
currentWorkingDirectory,
|
|
36
|
+
parsedArguments.get('config', ''),
|
|
37
|
+
)
|
|
38
|
+
const targets = configuredTargets(currentWorkingDirectory, config, shouldDeleteStores)
|
|
39
|
+
const candidates = [
|
|
40
|
+
...targets.fileTargets.filter(isNsdbGeneratedFile),
|
|
41
|
+
...targets.directoryTargets.flatMap(listGeneratedFiles),
|
|
42
|
+
]
|
|
43
|
+
const uniqueCandidates = [...new Set(candidates)]
|
|
44
|
+
|
|
45
|
+
for (const filePath of uniqueCandidates) {
|
|
46
|
+
removeGeneratedFile(filePath, { dryRun, verbose })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (dryRun) {
|
|
50
|
+
console.log(`Cleanup preview: ${uniqueCandidates.length} generated file(s) would be removed.`)
|
|
51
|
+
} else {
|
|
52
|
+
console.log(`Cleanup completed: ${uniqueCandidates.length} generated file(s) removed.`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return uniqueCandidates
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
59
|
+
clearGeneratedFiles().catch((error) => {
|
|
60
|
+
console.error('Failed to clear NSDB generated files.')
|
|
61
|
+
console.error(error)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
})
|
|
64
|
+
}
|