@effect-app/infra 4.0.0-beta.11 → 4.0.0-beta.111

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 (157) hide show
  1. package/CHANGELOG.md +729 -0
  2. package/dist/CUPS.d.ts +3 -3
  3. package/dist/CUPS.d.ts.map +1 -1
  4. package/dist/CUPS.js +3 -3
  5. package/dist/Emailer/service.d.ts +3 -3
  6. package/dist/Emailer/service.d.ts.map +1 -1
  7. package/dist/Emailer/service.js +3 -3
  8. package/dist/MainFiberSet.d.ts +2 -2
  9. package/dist/MainFiberSet.d.ts.map +1 -1
  10. package/dist/MainFiberSet.js +3 -3
  11. package/dist/Model/Repository/internal/internal.d.ts +3 -3
  12. package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
  13. package/dist/Model/Repository/internal/internal.js +21 -16
  14. package/dist/Model/Repository/makeRepo.d.ts +2 -2
  15. package/dist/Model/Repository/makeRepo.d.ts.map +1 -1
  16. package/dist/Model/Repository/makeRepo.js +1 -1
  17. package/dist/Model/Repository/validation.d.ts +5 -4
  18. package/dist/Model/Repository/validation.d.ts.map +1 -1
  19. package/dist/Model/query/dsl.d.ts +9 -9
  20. package/dist/Operations.d.ts +2 -2
  21. package/dist/Operations.d.ts.map +1 -1
  22. package/dist/Operations.js +3 -3
  23. package/dist/OperationsRepo.d.ts +2 -2
  24. package/dist/OperationsRepo.d.ts.map +1 -1
  25. package/dist/OperationsRepo.js +3 -3
  26. package/dist/QueueMaker/SQLQueue.d.ts +2 -4
  27. package/dist/QueueMaker/SQLQueue.d.ts.map +1 -1
  28. package/dist/QueueMaker/SQLQueue.js +8 -6
  29. package/dist/QueueMaker/errors.d.ts +1 -1
  30. package/dist/QueueMaker/errors.d.ts.map +1 -1
  31. package/dist/QueueMaker/memQueue.js +3 -3
  32. package/dist/QueueMaker/sbqueue.js +3 -3
  33. package/dist/RequestContext.d.ts +19 -14
  34. package/dist/RequestContext.d.ts.map +1 -1
  35. package/dist/RequestContext.js +5 -5
  36. package/dist/RequestFiberSet.d.ts +2 -2
  37. package/dist/RequestFiberSet.d.ts.map +1 -1
  38. package/dist/RequestFiberSet.js +5 -5
  39. package/dist/Store/ContextMapContainer.d.ts +19 -3
  40. package/dist/Store/ContextMapContainer.d.ts.map +1 -1
  41. package/dist/Store/ContextMapContainer.js +13 -3
  42. package/dist/Store/Cosmos.d.ts.map +1 -1
  43. package/dist/Store/Cosmos.js +136 -68
  44. package/dist/Store/Disk.d.ts.map +1 -1
  45. package/dist/Store/Disk.js +3 -4
  46. package/dist/Store/Memory.d.ts +2 -2
  47. package/dist/Store/Memory.d.ts.map +1 -1
  48. package/dist/Store/Memory.js +4 -4
  49. package/dist/Store/SQL/Pg.d.ts +4 -0
  50. package/dist/Store/SQL/Pg.d.ts.map +1 -0
  51. package/dist/Store/SQL/Pg.js +186 -0
  52. package/dist/Store/SQL/query.d.ts +38 -0
  53. package/dist/Store/SQL/query.d.ts.map +1 -0
  54. package/dist/Store/SQL/query.js +367 -0
  55. package/dist/Store/SQL.d.ts +20 -0
  56. package/dist/Store/SQL.d.ts.map +1 -0
  57. package/dist/Store/SQL.js +370 -0
  58. package/dist/Store/index.d.ts +4 -1
  59. package/dist/Store/index.d.ts.map +1 -1
  60. package/dist/Store/index.js +12 -2
  61. package/dist/Store/service.d.ts +11 -5
  62. package/dist/Store/service.d.ts.map +1 -1
  63. package/dist/Store/service.js +24 -6
  64. package/dist/adapters/ServiceBus.d.ts +6 -6
  65. package/dist/adapters/ServiceBus.d.ts.map +1 -1
  66. package/dist/adapters/ServiceBus.js +9 -9
  67. package/dist/adapters/cosmos-client.d.ts +2 -2
  68. package/dist/adapters/cosmos-client.d.ts.map +1 -1
  69. package/dist/adapters/cosmos-client.js +3 -3
  70. package/dist/adapters/logger.d.ts.map +1 -1
  71. package/dist/adapters/memQueue.d.ts +2 -2
  72. package/dist/adapters/memQueue.d.ts.map +1 -1
  73. package/dist/adapters/memQueue.js +3 -3
  74. package/dist/adapters/mongo-client.d.ts +2 -2
  75. package/dist/adapters/mongo-client.d.ts.map +1 -1
  76. package/dist/adapters/mongo-client.js +3 -3
  77. package/dist/adapters/redis-client.d.ts +3 -3
  78. package/dist/adapters/redis-client.d.ts.map +1 -1
  79. package/dist/adapters/redis-client.js +3 -3
  80. package/dist/api/ContextProvider.d.ts +6 -6
  81. package/dist/api/ContextProvider.d.ts.map +1 -1
  82. package/dist/api/ContextProvider.js +6 -6
  83. package/dist/api/internal/auth.d.ts +1 -1
  84. package/dist/api/internal/events.d.ts +2 -2
  85. package/dist/api/internal/events.d.ts.map +1 -1
  86. package/dist/api/internal/events.js +6 -4
  87. package/dist/api/layerUtils.d.ts +5 -5
  88. package/dist/api/layerUtils.d.ts.map +1 -1
  89. package/dist/api/layerUtils.js +5 -5
  90. package/dist/api/routing/middleware/RouterMiddleware.d.ts +3 -3
  91. package/dist/api/routing/middleware/RouterMiddleware.d.ts.map +1 -1
  92. package/dist/api/routing/middleware/middleware.d.ts +35 -1
  93. package/dist/api/routing/middleware/middleware.d.ts.map +1 -1
  94. package/dist/api/routing/middleware/middleware.js +39 -1
  95. package/dist/api/routing.d.ts +1 -5
  96. package/dist/api/routing.d.ts.map +1 -1
  97. package/dist/api/routing.js +3 -2
  98. package/dist/api/setupRequest.d.ts +6 -3
  99. package/dist/api/setupRequest.d.ts.map +1 -1
  100. package/dist/api/setupRequest.js +11 -6
  101. package/dist/logger.d.ts.map +1 -1
  102. package/examples/query.ts +30 -26
  103. package/package.json +32 -18
  104. package/src/CUPS.ts +2 -2
  105. package/src/Emailer/service.ts +2 -2
  106. package/src/MainFiberSet.ts +2 -2
  107. package/src/Model/Repository/internal/internal.ts +75 -59
  108. package/src/Model/Repository/makeRepo.ts +2 -2
  109. package/src/Operations.ts +2 -2
  110. package/src/OperationsRepo.ts +2 -2
  111. package/src/QueueMaker/SQLQueue.ts +8 -7
  112. package/src/QueueMaker/memQueue.ts +2 -2
  113. package/src/QueueMaker/sbqueue.ts +2 -2
  114. package/src/RequestContext.ts +4 -4
  115. package/src/RequestFiberSet.ts +4 -4
  116. package/src/Store/ContextMapContainer.ts +41 -2
  117. package/src/Store/Cosmos.ts +352 -255
  118. package/src/Store/Disk.ts +2 -3
  119. package/src/Store/Memory.ts +4 -4
  120. package/src/Store/SQL/Pg.ts +328 -0
  121. package/src/Store/SQL/query.ts +409 -0
  122. package/src/Store/SQL.ts +686 -0
  123. package/src/Store/index.ts +15 -1
  124. package/src/Store/service.ts +26 -7
  125. package/src/adapters/ServiceBus.ts +8 -8
  126. package/src/adapters/cosmos-client.ts +2 -2
  127. package/src/adapters/memQueue.ts +2 -2
  128. package/src/adapters/mongo-client.ts +2 -2
  129. package/src/adapters/redis-client.ts +2 -2
  130. package/src/api/ContextProvider.ts +11 -11
  131. package/src/api/internal/events.ts +6 -5
  132. package/src/api/layerUtils.ts +8 -8
  133. package/src/api/routing/middleware/RouterMiddleware.ts +4 -4
  134. package/src/api/routing/middleware/middleware.ts +43 -0
  135. package/src/api/routing.ts +3 -3
  136. package/src/api/setupRequest.ts +27 -7
  137. package/test/contextProvider.test.ts +11 -11
  138. package/test/controller.test.ts +12 -9
  139. package/test/dist/contextProvider.test.d.ts.map +1 -1
  140. package/test/dist/controller.test.d.ts.map +1 -1
  141. package/test/dist/date-query.test.d.ts.map +1 -0
  142. package/test/dist/fixtures.d.ts +18 -8
  143. package/test/dist/fixtures.d.ts.map +1 -1
  144. package/test/dist/fixtures.js +11 -9
  145. package/test/dist/query.test.d.ts.map +1 -1
  146. package/test/dist/rawQuery.test.d.ts.map +1 -1
  147. package/test/dist/requires.test.d.ts.map +1 -1
  148. package/test/dist/rpc-multi-middleware.test.d.ts.map +1 -1
  149. package/test/dist/sql-store.test.d.ts.map +1 -0
  150. package/test/fixtures.ts +10 -8
  151. package/test/query.test.ts +162 -16
  152. package/test/rawQuery.test.ts +19 -17
  153. package/test/requires.test.ts +6 -5
  154. package/test/rpc-multi-middleware.test.ts +72 -3
  155. package/test/sql-store.test.ts +1064 -0
  156. package/test/validateSample.test.ts +1 -1
  157. package/tsconfig.json +0 -1
@@ -0,0 +1,686 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import { Context, Layer, LayerMap } from "effect"
4
+ import { Effect, type NonEmptyReadonlyArray, Option, Struct } from "effect-app"
5
+ import { toNonEmptyArray } from "effect-app/Array"
6
+ import { SqlClient } from "effect/unstable/sql"
7
+ import { OptimisticConcurrencyException } from "../errors.js"
8
+ import { InfraLogger } from "../logger.js"
9
+ import type { FieldValues } from "../Model/filter/types.js"
10
+ import { storeId } from "./Memory.js"
11
+ import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, StoreMaker } from "./service.js"
12
+ import { buildWhereSQLQuery, logQuery, type SQLDialect, sqliteDialect } from "./SQL/query.js"
13
+ import { makeETag } from "./utils.js"
14
+
15
+ export type WithNsTransactionFn = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
16
+
17
+ export class WithNsTransaction
18
+ extends Context.Service<WithNsTransaction, WithNsTransactionFn>()("effect-app/WithNsTransaction")
19
+ {}
20
+
21
+ /** @internal */
22
+ export const parseRow = <Encoded extends FieldValues>(
23
+ row: { id: string; _etag: string | null; data: string },
24
+ idKey: PropertyKey,
25
+ defaultValues: Partial<Encoded>
26
+ ): PersistenceModelType<Encoded> => {
27
+ const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
28
+ return { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
29
+ }
30
+
31
+ const parseSelectRow = (
32
+ row: Record<string, unknown>,
33
+ idKey: PropertyKey
34
+ ): any => {
35
+ const result: Record<string, unknown> = {}
36
+ for (const [key, value] of Object.entries(row)) {
37
+ if (key === "id") {
38
+ result[idKey as string] = value
39
+ result["id"] = value
40
+ } else if (typeof value === "string") {
41
+ try {
42
+ result[key] = JSON.parse(value)
43
+ } catch {
44
+ result[key] = value
45
+ }
46
+ } else {
47
+ result[key] = value
48
+ }
49
+ }
50
+ return result
51
+ }
52
+
53
+ function makeSQLStoreInt(dialect: SQLDialect, jsonColumnType: string) {
54
+ return ({ prefix }: StorageConfig) =>
55
+ Effect.gen(function*() {
56
+ const sql = yield* SqlClient.SqlClient
57
+ return {
58
+ make: <IdKey extends keyof Encoded, Encoded extends FieldValues, R = never, E = never>(
59
+ name: string,
60
+ idKey: IdKey,
61
+ seed?: Effect.Effect<Iterable<Encoded>, E, R>,
62
+ config?: StoreConfig<Encoded>
63
+ ) =>
64
+ Effect.gen(function*() {
65
+ type PM = PersistenceModelType<Encoded>
66
+ const tableName = `${prefix}${name}`
67
+ const defaultValues = config?.defaultValues ?? {}
68
+
69
+ const resolveNamespace = !config?.allowNamespace
70
+ ? Effect.succeed("primary")
71
+ : storeId.asEffect().pipe(Effect.map((namespace) => {
72
+ if (namespace !== "primary" && !config.allowNamespace!(namespace)) {
73
+ throw new Error(`Namespace ${namespace} not allowed!`)
74
+ }
75
+ return namespace
76
+ }))
77
+
78
+ yield* sql
79
+ .unsafe(
80
+ `CREATE TABLE IF NOT EXISTS "${tableName}" (id TEXT NOT NULL, _namespace TEXT NOT NULL DEFAULT 'primary', _etag TEXT, data ${jsonColumnType} NOT NULL, PRIMARY KEY (id, _namespace))`
81
+ )
82
+ .pipe(Effect.orDie)
83
+
84
+ const toRow = (e: PM) => {
85
+ const newE = makeETag(e)
86
+ const id = newE[idKey] as string
87
+ const { _etag, [idKey]: _id, ...rest } = newE as any
88
+ const data = JSON.stringify(rest)
89
+ return { id, _etag: newE._etag!, data, item: newE }
90
+ }
91
+
92
+ const exec = (query: string, params?: readonly unknown[]) =>
93
+ sql.unsafe(query, params as any).pipe(Effect.orDie)
94
+
95
+ const seedMarkerId = `__seed_marker__`
96
+
97
+ const setInternal = (e: PM, ns: string) =>
98
+ Effect.gen(function*() {
99
+ const row = toRow(e)
100
+ if (e._etag) {
101
+ yield* exec(
102
+ `UPDATE "${tableName}" SET _etag = ?, data = ? WHERE id = ? AND _etag = ? AND _namespace = ?`,
103
+ [row._etag, row.data, row.id, e._etag, ns]
104
+ )
105
+ const existing = yield* exec(
106
+ `SELECT _etag FROM "${tableName}" WHERE id = ? AND _namespace = ?`,
107
+ [row.id, ns]
108
+ )
109
+ const current = (existing as any[])[0]
110
+ if (!current || current._etag !== row._etag) {
111
+ if (current) {
112
+ return yield* new OptimisticConcurrencyException({
113
+ type: name,
114
+ id: row.id,
115
+ current: current._etag,
116
+ found: e._etag,
117
+ code: 412
118
+ })
119
+ }
120
+ return yield* new OptimisticConcurrencyException({
121
+ type: name,
122
+ id: row.id,
123
+ current: "",
124
+ found: e._etag,
125
+ code: 404
126
+ })
127
+ }
128
+ } else {
129
+ yield* exec(
130
+ `INSERT INTO "${tableName}" (id, _namespace, _etag, data) VALUES (?, ?, ?, ?)`,
131
+ [row.id, ns, row._etag, row.data]
132
+ )
133
+ }
134
+ return row.item
135
+ })
136
+
137
+ const bulkSetInternal = (items: NonEmptyReadonlyArray<PM>, ns: string) =>
138
+ sql
139
+ .withTransaction(Effect.forEach(items, (e) => setInternal(e, ns)))
140
+ .pipe(
141
+ Effect.orDie,
142
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>)
143
+ )
144
+
145
+ const ctx = yield* Effect.context<R>()
146
+ const seedCache = new Map<string, Effect.Effect<void>>()
147
+ const makeSeedEffect = (ns: string) =>
148
+ exec(
149
+ `SELECT id FROM "${tableName}" WHERE id = ? AND _namespace = ?`,
150
+ [seedMarkerId, `__seed__::${ns}`]
151
+ )
152
+ .pipe(
153
+ Effect.flatMap((existing) => {
154
+ if ((existing as any[]).length > 0) return Effect.void
155
+ return InfraLogger.logInfo(`Seeding data for ${name} (namespace: ${ns})`).pipe(
156
+ Effect.andThen(seed!),
157
+ Effect.flatMap((items) =>
158
+ Effect.flatMapOption(
159
+ Effect.succeed(toNonEmptyArray([...items])),
160
+ (a) => bulkSetInternal(a, ns)
161
+ )
162
+ ),
163
+ Effect.andThen(
164
+ exec(
165
+ `INSERT INTO "${tableName}" (id, _namespace, _etag, data) VALUES (?, ?, ?, ?)`,
166
+ [seedMarkerId, `__seed__::${ns}`, null, JSON.stringify({ _marker: true })]
167
+ )
168
+ ),
169
+ Effect.provide(ctx),
170
+ Effect.orDie
171
+ )
172
+ })
173
+ )
174
+ const seedNamespace = Effect.fn("seedNamespace")(function*(ns: string) {
175
+ if (!seed) return
176
+ let cached = seedCache.get(ns)
177
+ if (!cached) {
178
+ cached = yield* Effect.cached(makeSeedEffect(ns))
179
+ seedCache.set(ns, cached)
180
+ }
181
+ yield* cached
182
+ })
183
+ const resolveAndSeed = resolveNamespace.pipe(
184
+ Effect.tap((ns) => seedNamespace(ns))
185
+ )
186
+
187
+ const s: Store<IdKey, Encoded> = {
188
+ all: resolveAndSeed.pipe(Effect.flatMap((ns) =>
189
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = ?`, [ns])
190
+ .pipe(
191
+ Effect.map((rows) => (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues))),
192
+ Effect.withSpan("SQL.all [effect-app/infra/Store]", {
193
+ attributes: {
194
+ "repository.table_name": tableName,
195
+ "repository.model_name": name,
196
+ "repository.namespace": ns
197
+ }
198
+ }, { captureStackTrace: false })
199
+ )
200
+ )),
201
+
202
+ find: (id) =>
203
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
204
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE id = ? AND _namespace = ?`, [id, ns])
205
+ .pipe(
206
+ Effect.map((rows) => {
207
+ const row = (rows as any[])[0]
208
+ return row
209
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues))
210
+ : Option.none()
211
+ }),
212
+ Effect.withSpan("SQL.find [effect-app/infra/Store]", {
213
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id }
214
+ }, { captureStackTrace: false })
215
+ )
216
+ )),
217
+
218
+ filter: <U extends keyof Encoded = never>(f: FilterArgs<Encoded, U>) => {
219
+ const filter = f
220
+ .filter
221
+ type M = U extends undefined ? Encoded
222
+ : Pick<Encoded, U>
223
+ return resolveAndSeed
224
+ .pipe(Effect
225
+ .flatMap((ns) =>
226
+ Effect
227
+ .sync(() => {
228
+ const q = buildWhereSQLQuery(
229
+ dialect,
230
+ idKey,
231
+ filter ? [{ t: "where-scope", result: filter, relation: "some" }] : [],
232
+ tableName,
233
+ defaultValues,
234
+ f
235
+ .select as
236
+ | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
237
+ | undefined,
238
+ f
239
+ .order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
240
+ f
241
+ .skip,
242
+ f
243
+ .limit
244
+ )
245
+ const hasWhere = q
246
+ .sql
247
+ .includes("WHERE")
248
+ const nsSql = hasWhere
249
+ ? q
250
+ .sql
251
+ .replace("WHERE", `WHERE _namespace = ? AND`)
252
+ : q
253
+ .sql
254
+ .replace(
255
+ `FROM "${tableName}"`,
256
+ `FROM "${tableName}" WHERE _namespace = ?`
257
+ )
258
+ return {
259
+ sql: nsSql,
260
+ params: [
261
+ ns,
262
+ ...q
263
+ .params
264
+ ]
265
+ }
266
+ })
267
+ .pipe(
268
+ Effect
269
+ .tap((q) =>
270
+ logQuery(q)
271
+ ),
272
+ Effect.flatMap((q) =>
273
+ exec(q.sql, q.params).pipe(
274
+ Effect.map((rows) => {
275
+ if (f.select) {
276
+ return (rows as any[]).map((r) => {
277
+ const selected = parseSelectRow(r, idKey)
278
+ return {
279
+ ...Struct.pick(
280
+ defaultValues as any,
281
+ f.select!.filter((_) => typeof _ === "string") as never[]
282
+ ),
283
+ ...selected
284
+ } as M
285
+ })
286
+ }
287
+ return (rows as any[]).map((r) =>
288
+ parseRow<Encoded>(r, idKey, defaultValues) as any as M
289
+ )
290
+ })
291
+ )
292
+ ),
293
+ Effect.withSpan("SQL.filter [effect-app/infra/Store]", {
294
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
295
+ }, { captureStackTrace: false })
296
+ )
297
+ ))
298
+ },
299
+
300
+ set: (e) =>
301
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
302
+ setInternal(e, ns).pipe(
303
+ Effect.withSpan("SQL.set [effect-app/infra/Store]", {
304
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id: e[idKey] }
305
+ }, { captureStackTrace: false })
306
+ )
307
+ )),
308
+
309
+ batchSet: (items) =>
310
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
311
+ bulkSetInternal(items, ns).pipe(
312
+ Effect.withSpan("SQL.batchSet [effect-app/infra/Store]", {
313
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
314
+ }, { captureStackTrace: false })
315
+ )
316
+ )),
317
+
318
+ bulkSet: (items) =>
319
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
320
+ bulkSetInternal(items, ns).pipe(
321
+ Effect.withSpan("SQL.bulkSet [effect-app/infra/Store]", {
322
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
323
+ }, { captureStackTrace: false })
324
+ )
325
+ )),
326
+
327
+ batchRemove: (ids) => {
328
+ const placeholders = ids.map(() => "?").join(", ")
329
+ return resolveAndSeed.pipe(Effect.flatMap((ns) =>
330
+ exec(
331
+ `DELETE FROM "${tableName}" WHERE id IN (${placeholders}) AND _namespace = ?`,
332
+ [...ids, ns]
333
+ )
334
+ .pipe(
335
+ Effect.asVoid,
336
+ Effect.withSpan("SQL.batchRemove [effect-app/infra/Store]", {
337
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
338
+ }, { captureStackTrace: false })
339
+ )
340
+ ))
341
+ },
342
+
343
+ queryRaw: (query) =>
344
+ s.all.pipe(
345
+ Effect.map(query.memory),
346
+ Effect.withSpan("SQL.queryRaw [effect-app/infra/Store]", {
347
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
348
+ }, { captureStackTrace: false })
349
+ )
350
+ }
351
+
352
+ // Eagerly seed primary namespace on initialization
353
+ yield* seedNamespace("primary")
354
+
355
+ return s
356
+ })
357
+ }
358
+ })
359
+ }
360
+
361
+ type WithNsSqlFn = <A, E2, R2>(
362
+ ns: string,
363
+ f: (sql: SqlClient.SqlClient) => Effect.Effect<A, E2, R2>
364
+ ) => Effect.Effect<A, E2, R2>
365
+
366
+ function makeSQLiteStorePerNs(
367
+ withNsSql: WithNsSqlFn,
368
+ { prefix }: StorageConfig
369
+ ) {
370
+ return {
371
+ make: <IdKey extends keyof Encoded, Encoded extends FieldValues, R = never, E = never>(
372
+ name: string,
373
+ idKey: IdKey,
374
+ seed?: Effect.Effect<Iterable<Encoded>, E, R>,
375
+ config?: StoreConfig<Encoded>
376
+ ) =>
377
+ Effect.gen(function*() {
378
+ type PM = PersistenceModelType<Encoded>
379
+ const tableName = `${prefix}${name}`
380
+ const defaultValues = config?.defaultValues ?? {}
381
+
382
+ const resolveNamespace = !config?.allowNamespace
383
+ ? Effect.succeed("primary")
384
+ : storeId.asEffect().pipe(Effect.map((namespace) => {
385
+ if (namespace !== "primary" && !config.allowNamespace!(namespace)) {
386
+ throw new Error(`Namespace ${namespace} not allowed!`)
387
+ }
388
+ return namespace
389
+ }))
390
+
391
+ const toRow = (e: PM) => {
392
+ const newE = makeETag(e)
393
+ const id = newE[idKey] as string
394
+ const { _etag, [idKey]: _id, ...rest } = newE as any
395
+ const data = JSON.stringify(rest)
396
+ return { id, _etag: newE._etag!, data, item: newE }
397
+ }
398
+
399
+ const exec = (ns: string, query: string, params?: readonly unknown[]) =>
400
+ withNsSql(ns, (sql) => sql.unsafe(query, params as any).pipe(Effect.orDie))
401
+
402
+ const ensureTable = (ns: string) =>
403
+ exec(
404
+ ns,
405
+ `CREATE TABLE IF NOT EXISTS "${tableName}" (id TEXT NOT NULL PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`
406
+ )
407
+
408
+ const seedMarkerId = `__seed_marker__`
409
+
410
+ const setInternal = (e: PM, ns: string) =>
411
+ Effect.gen(function*() {
412
+ const row = toRow(e)
413
+ if (e._etag) {
414
+ yield* exec(
415
+ ns,
416
+ `UPDATE "${tableName}" SET _etag = ?, data = ? WHERE id = ? AND _etag = ?`,
417
+ [row._etag, row.data, row.id, e._etag]
418
+ )
419
+ const existing = yield* exec(
420
+ ns,
421
+ `SELECT _etag FROM "${tableName}" WHERE id = ?`,
422
+ [row.id]
423
+ )
424
+ const current = (existing as any[])[0]
425
+ if (!current || current._etag !== row._etag) {
426
+ if (current) {
427
+ return yield* new OptimisticConcurrencyException({
428
+ type: name,
429
+ id: row.id,
430
+ current: current._etag,
431
+ found: e._etag,
432
+ code: 412
433
+ })
434
+ }
435
+ return yield* new OptimisticConcurrencyException({
436
+ type: name,
437
+ id: row.id,
438
+ current: "",
439
+ found: e._etag,
440
+ code: 404
441
+ })
442
+ }
443
+ } else {
444
+ yield* exec(
445
+ ns,
446
+ `INSERT INTO "${tableName}" (id, _etag, data) VALUES (?, ?, ?)`,
447
+ [row.id, row._etag, row.data]
448
+ )
449
+ }
450
+ return row.item
451
+ })
452
+
453
+ const bulkSetInternal = (items: NonEmptyReadonlyArray<PM>, ns: string) =>
454
+ withNsSql(ns, (sql) =>
455
+ sql
456
+ .withTransaction(Effect.forEach(items, (e) => setInternal(e, ns)))
457
+ .pipe(
458
+ Effect.orDie,
459
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>)
460
+ ))
461
+
462
+ const ctx = yield* Effect.context<R>()
463
+ const seedCache = new Map<string, Effect.Effect<void>>()
464
+ const makeSeedEffect = (ns: string) =>
465
+ exec(
466
+ ns,
467
+ `SELECT id FROM "${tableName}" WHERE id = ?`,
468
+ [seedMarkerId]
469
+ )
470
+ .pipe(
471
+ Effect.flatMap((existing) => {
472
+ if ((existing as any[]).length > 0) return Effect.void
473
+ return InfraLogger.logInfo(`Seeding data for ${name} (namespace: ${ns})`).pipe(
474
+ Effect.andThen(seed!),
475
+ Effect.flatMap((items) =>
476
+ Effect.flatMapOption(
477
+ Effect.succeed(toNonEmptyArray([...items])),
478
+ (a) => bulkSetInternal(a, ns)
479
+ )
480
+ ),
481
+ Effect.andThen(
482
+ exec(
483
+ ns,
484
+ `INSERT INTO "${tableName}" (id, _etag, data) VALUES (?, ?, ?)`,
485
+ [seedMarkerId, null, JSON.stringify({ _marker: true })]
486
+ )
487
+ ),
488
+ Effect.provide(ctx),
489
+ Effect.orDie
490
+ )
491
+ })
492
+ )
493
+ const seedNamespace = Effect.fn("seedNamespace")(function*(ns: string) {
494
+ yield* ensureTable(ns)
495
+ if (!seed) return
496
+ let cached = seedCache.get(ns)
497
+ if (!cached) {
498
+ cached = yield* Effect.cached(makeSeedEffect(ns))
499
+ seedCache.set(ns, cached)
500
+ }
501
+ yield* cached
502
+ })
503
+ const resolveAndSeed = resolveNamespace.pipe(
504
+ Effect.tap((ns) => seedNamespace(ns))
505
+ )
506
+
507
+ const s: Store<IdKey, Encoded> = {
508
+ all: resolveAndSeed.pipe(Effect.flatMap((ns) =>
509
+ exec(ns, `SELECT id, _etag, data FROM "${tableName}"`)
510
+ .pipe(
511
+ Effect.map((rows) => (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues))),
512
+ Effect.withSpan("SQLite.all [effect-app/infra/Store]", {
513
+ attributes: {
514
+ "repository.table_name": tableName,
515
+ "repository.model_name": name,
516
+ "repository.namespace": ns
517
+ }
518
+ }, { captureStackTrace: false })
519
+ )
520
+ )),
521
+
522
+ find: (id) =>
523
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
524
+ exec(ns, `SELECT id, _etag, data FROM "${tableName}" WHERE id = ?`, [id])
525
+ .pipe(
526
+ Effect.map((rows) => {
527
+ const row = (rows as any[])[0]
528
+ return row
529
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues))
530
+ : Option.none()
531
+ }),
532
+ Effect.withSpan("SQLite.find [effect-app/infra/Store]", {
533
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id }
534
+ }, { captureStackTrace: false })
535
+ )
536
+ )),
537
+
538
+ filter: <U extends keyof Encoded = never>(f: FilterArgs<Encoded, U>) => {
539
+ const filter = f
540
+ .filter
541
+ type M = U extends undefined ? Encoded
542
+ : Pick<Encoded, U>
543
+ return resolveAndSeed
544
+ .pipe(Effect
545
+ .flatMap((ns) =>
546
+ Effect
547
+ .sync(() =>
548
+ buildWhereSQLQuery(
549
+ sqliteDialect,
550
+ idKey,
551
+ filter ? [{ t: "where-scope", result: filter, relation: "some" }] : [],
552
+ tableName,
553
+ defaultValues,
554
+ f
555
+ .select as
556
+ | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
557
+ | undefined,
558
+ f
559
+ .order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
560
+ f
561
+ .skip,
562
+ f
563
+ .limit
564
+ )
565
+ )
566
+ .pipe(
567
+ Effect
568
+ .tap((q) =>
569
+ logQuery(q)
570
+ ),
571
+ Effect.flatMap((q) =>
572
+ exec(ns, q.sql, q.params).pipe(
573
+ Effect.map((rows) => {
574
+ if (f.select) {
575
+ return (rows as any[]).map((r) => {
576
+ const selected = parseSelectRow(r, idKey)
577
+ return {
578
+ ...Struct.pick(
579
+ defaultValues as any,
580
+ f.select!.filter((_) => typeof _ === "string") as never[]
581
+ ),
582
+ ...selected
583
+ } as M
584
+ })
585
+ }
586
+ return (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues) as any as M)
587
+ })
588
+ )
589
+ ),
590
+ Effect.withSpan("SQLite.filter [effect-app/infra/Store]", {
591
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
592
+ }, { captureStackTrace: false })
593
+ )
594
+ ))
595
+ },
596
+
597
+ set: (e) =>
598
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
599
+ setInternal(e, ns).pipe(
600
+ Effect.withSpan("SQLite.set [effect-app/infra/Store]", {
601
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id: e[idKey] }
602
+ }, { captureStackTrace: false })
603
+ )
604
+ )),
605
+
606
+ batchSet: (items) =>
607
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
608
+ bulkSetInternal(items, ns).pipe(
609
+ Effect.withSpan("SQLite.batchSet [effect-app/infra/Store]", {
610
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
611
+ }, { captureStackTrace: false })
612
+ )
613
+ )),
614
+
615
+ bulkSet: (items) =>
616
+ resolveAndSeed.pipe(Effect.flatMap((ns) =>
617
+ bulkSetInternal(items, ns).pipe(
618
+ Effect.withSpan("SQLite.bulkSet [effect-app/infra/Store]", {
619
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
620
+ }, { captureStackTrace: false })
621
+ )
622
+ )),
623
+
624
+ batchRemove: (ids) => {
625
+ const placeholders = ids.map(() => "?").join(", ")
626
+ return resolveAndSeed.pipe(Effect.flatMap((ns) =>
627
+ exec(
628
+ ns,
629
+ `DELETE FROM "${tableName}" WHERE id IN (${placeholders})`,
630
+ [...ids]
631
+ )
632
+ .pipe(
633
+ Effect.asVoid,
634
+ Effect.withSpan("SQLite.batchRemove [effect-app/infra/Store]", {
635
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
636
+ }, { captureStackTrace: false })
637
+ )
638
+ ))
639
+ },
640
+
641
+ queryRaw: (query) =>
642
+ s.all.pipe(
643
+ Effect.map(query.memory),
644
+ Effect.withSpan("SQLite.queryRaw [effect-app/infra/Store]", {
645
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
646
+ }, { captureStackTrace: false })
647
+ )
648
+ }
649
+
650
+ yield* seedNamespace("primary")
651
+
652
+ return s
653
+ })
654
+ }
655
+ }
656
+
657
+ export function SQLiteStoreLayer(
658
+ cfg: StorageConfig,
659
+ options?: { makeSqlClientLayer?: (namespace: string) => Layer.Layer<SqlClient.SqlClient> }
660
+ ) {
661
+ if (options?.makeSqlClientLayer) {
662
+ return Layer.effectContext(
663
+ Effect.gen(function*() {
664
+ const layerMap = yield* LayerMap.make(
665
+ (namespace: string) => options.makeSqlClientLayer!(namespace),
666
+ { idleTimeToLive: "10 minutes" }
667
+ )
668
+
669
+ const withNsSql: WithNsSqlFn = (ns, f) => SqlClient.SqlClient.use(f).pipe(Effect.provide(layerMap.get(ns)))
670
+
671
+ const storeMaker = makeSQLiteStorePerNs(withNsSql, cfg)
672
+
673
+ const withTransaction: WithNsTransactionFn = (effect) =>
674
+ storeId.asEffect().pipe(
675
+ Effect.flatMap((ns) => withNsSql(ns, (sql) => sql.withTransaction(effect).pipe(Effect.orDie)))
676
+ ) as any
677
+
678
+ return StoreMaker.contextMap(storeMaker).pipe(
679
+ Context.add(WithNsTransaction, withTransaction)
680
+ )
681
+ })
682
+ )
683
+ }
684
+ return StoreMaker
685
+ .toLayer(makeSQLStoreInt(sqliteDialect, "JSON")(cfg))
686
+ }