@effect-app/infra 4.0.0-beta.12 → 4.0.0-beta.121

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