@effect-app/infra 4.0.0-beta.83 → 4.0.0-beta.85

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.
@@ -2,7 +2,9 @@
2
2
  import type Sqlite from "better-sqlite3"
3
3
  import BetterSqlite from "better-sqlite3"
4
4
  import { describe, expect, it } from "vitest"
5
+ import { parseRow } from "../src/Store/SQL.js"
5
6
  import { buildWhereSQLQuery, pgDialect, sqliteDialect } from "../src/Store/SQL/query.js"
7
+ import { makeETag } from "../src/Store/utils.js"
6
8
 
7
9
  const query = (db: Sqlite.Database, sql: string, params: unknown[] = []) =>
8
10
  db.prepare(sql).all(...params as any[]) as any[]
@@ -309,7 +311,8 @@ describe("SQL Store (SQLite integration)", () => {
309
311
  db.exec(
310
312
  `CREATE TABLE IF NOT EXISTS "test_items" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`
311
313
  )
312
- db.prepare(`INSERT INTO "test_items" (id, _etag, data) VALUES (?, ?, ?)`)
314
+ db
315
+ .prepare(`INSERT INTO "test_items" (id, _etag, data) VALUES (?, ?, ?)`)
313
316
  .run("1", "etag1", JSON.stringify({ name: "Alice", age: 30 }))
314
317
 
315
318
  const rows = db.prepare(`SELECT * FROM "test_items"`).all()
@@ -324,7 +327,8 @@ describe("SQL Store (SQLite integration)", () => {
324
327
  )
325
328
  // Simulate what toRow now produces: data without id or _etag
326
329
  const data = { name: "Alice", age: 30, tags: ["admin"] }
327
- db.prepare(`INSERT INTO "test_clean" (id, _etag, data) VALUES (?, ?, ?)`)
330
+ db
331
+ .prepare(`INSERT INTO "test_clean" (id, _etag, data) VALUES (?, ?, ?)`)
328
332
  .run("1", "etag1", JSON.stringify(data))
329
333
 
330
334
  const row = db.prepare(`SELECT * FROM "test_clean" WHERE id = ?`).get("1") as any
@@ -345,26 +349,32 @@ describe("SQL Store (SQLite integration)", () => {
345
349
  `CREATE TABLE IF NOT EXISTS "test_compat" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`
346
350
  )
347
351
  // Old format: id and _etag inside data
348
- db.prepare(`INSERT INTO "test_compat" (id, _etag, data) VALUES (?, ?, ?)`)
352
+ db
353
+ .prepare(`INSERT INTO "test_compat" (id, _etag, data) VALUES (?, ?, ?)`)
349
354
  .run("1", "etag1", JSON.stringify({ id: "1", _etag: "old_etag", name: "Alice", age: 30 }))
350
355
  // New format: id and _etag stripped from data
351
- db.prepare(`INSERT INTO "test_compat" (id, _etag, data) VALUES (?, ?, ?)`)
356
+ db
357
+ .prepare(`INSERT INTO "test_compat" (id, _etag, data) VALUES (?, ?, ?)`)
352
358
  .run("2", "etag2", JSON.stringify({ name: "Bob", age: 25 }))
353
359
 
354
360
  // Both should be queryable by name
355
361
  const q1 = buildWhereSQLQuery(
356
- sqliteDialect, "id",
362
+ sqliteDialect,
363
+ "id",
357
364
  [{ t: "where", path: "name", op: "eq", value: "Alice" }],
358
- "test_compat", {}
365
+ "test_compat",
366
+ {}
359
367
  )
360
368
  const r1 = query(db, q1.sql, q1.params)
361
369
  expect(r1.length).toBe(1)
362
370
  expect((r1[0] as any).id).toBe("1")
363
371
 
364
372
  const q2 = buildWhereSQLQuery(
365
- sqliteDialect, "id",
373
+ sqliteDialect,
374
+ "id",
366
375
  [{ t: "where", path: "name", op: "eq", value: "Bob" }],
367
- "test_compat", {}
376
+ "test_compat",
377
+ {}
368
378
  )
369
379
  const r2 = query(db, q2.sql, q2.params)
370
380
  expect(r2.length).toBe(1)
@@ -372,9 +382,11 @@ describe("SQL Store (SQLite integration)", () => {
372
382
 
373
383
  // Both queryable by id column
374
384
  const q3 = buildWhereSQLQuery(
375
- sqliteDialect, "id",
385
+ sqliteDialect,
386
+ "id",
376
387
  [{ t: "where", path: "id", op: "in", value: ["1", "2"] as any }],
377
- "test_compat", {}
388
+ "test_compat",
389
+ {}
378
390
  )
379
391
  expect(query(db, q3.sql, q3.params).length).toBe(2)
380
392
  }))
@@ -396,17 +408,21 @@ describe("SQL Store (SQLite integration)", () => {
396
408
 
397
409
  // Filter by field in data
398
410
  const q1 = buildWhereSQLQuery(
399
- sqliteDialect, "id",
411
+ sqliteDialect,
412
+ "id",
400
413
  [{ t: "where", path: "age", op: "gt", value: 28 as any }],
401
- "test_noid", {}
414
+ "test_noid",
415
+ {}
402
416
  )
403
417
  expect(query(db, q1.sql, q1.params).length).toBe(2) // Alice(30), Charlie(35)
404
418
 
405
419
  // Filter by id column
406
420
  const q2 = buildWhereSQLQuery(
407
- sqliteDialect, "id",
421
+ sqliteDialect,
422
+ "id",
408
423
  [{ t: "where", path: "id", op: "eq", value: "2" }],
409
- "test_noid", {}
424
+ "test_noid",
425
+ {}
410
426
  )
411
427
  const r2 = query(db, q2.sql, q2.params)
412
428
  expect(r2.length).toBe(1)
@@ -415,10 +431,15 @@ describe("SQL Store (SQLite integration)", () => {
415
431
 
416
432
  // Order + limit still works
417
433
  const q3 = buildWhereSQLQuery(
418
- sqliteDialect, "id", [], "test_noid", {},
434
+ sqliteDialect,
435
+ "id",
436
+ [],
437
+ "test_noid",
438
+ {},
419
439
  undefined,
420
440
  [{ key: "age", direction: "ASC" }] as any,
421
- undefined, 2
441
+ undefined,
442
+ 2
422
443
  )
423
444
  const r3 = query(db, q3.sql, q3.params)
424
445
  expect(r3.length).toBe(2)
@@ -448,40 +469,48 @@ describe("SQL Store (SQLite integration)", () => {
448
469
 
449
470
  // Test eq
450
471
  const q1 = buildWhereSQLQuery(
451
- sqliteDialect, "id",
472
+ sqliteDialect,
473
+ "id",
452
474
  [{ t: "where", path: "name", op: "eq", value: "Alice" }],
453
- "test_people", {}
475
+ "test_people",
476
+ {}
454
477
  )
455
478
  expect(query(db, q1.sql, q1.params).length).toBe(1)
456
479
  expect((JSON.parse((query(db, q1.sql, q1.params)[0] as any).data) as any).name).toBe("Alice")
457
480
 
458
481
  // Test gt
459
482
  const q2 = buildWhereSQLQuery(
460
- sqliteDialect, "id",
483
+ sqliteDialect,
484
+ "id",
461
485
  [{ t: "where", path: "age", op: "gt", value: 28 as any }],
462
- "test_people", {}
486
+ "test_people",
487
+ {}
463
488
  )
464
489
  expect(query(db, q2.sql, q2.params).length).toBe(2)
465
490
 
466
491
  // Test OR
467
492
  const q3 = buildWhereSQLQuery(
468
- sqliteDialect, "id",
493
+ sqliteDialect,
494
+ "id",
469
495
  [
470
496
  { t: "where", path: "name", op: "eq", value: "Alice" },
471
497
  { t: "or", path: "name", op: "eq", value: "Bob" }
472
498
  ],
473
- "test_people", {}
499
+ "test_people",
500
+ {}
474
501
  )
475
502
  expect(query(db, q3.sql, q3.params).length).toBe(2)
476
503
 
477
504
  // Test AND
478
505
  const q4 = buildWhereSQLQuery(
479
- sqliteDialect, "id",
506
+ sqliteDialect,
507
+ "id",
480
508
  [
481
509
  { t: "where", path: "name", op: "eq", value: "Alice" },
482
510
  { t: "and", path: "age", op: "gt", value: 25 as any }
483
511
  ],
484
- "test_people", {}
512
+ "test_people",
513
+ {}
485
514
  )
486
515
  const r4 = query(db, q4.sql, q4.params)
487
516
  expect(r4.length).toBe(1)
@@ -489,25 +518,31 @@ describe("SQL Store (SQLite integration)", () => {
489
518
 
490
519
  // Test IN
491
520
  const q5 = buildWhereSQLQuery(
492
- sqliteDialect, "id",
521
+ sqliteDialect,
522
+ "id",
493
523
  [{ t: "where", path: "id", op: "in", value: ["1", "3"] as any }],
494
- "test_people", {}
524
+ "test_people",
525
+ {}
495
526
  )
496
527
  expect(query(db, q5.sql, q5.params).length).toBe(2)
497
528
 
498
529
  // Test contains (string)
499
530
  const q6 = buildWhereSQLQuery(
500
- sqliteDialect, "id",
531
+ sqliteDialect,
532
+ "id",
501
533
  [{ t: "where", path: "name", op: "contains", value: "li" }],
502
- "test_people", {}
534
+ "test_people",
535
+ {}
503
536
  )
504
537
  expect(query(db, q6.sql, q6.params).length).toBe(2) // Alice, Charlie
505
538
 
506
539
  // Test startsWith
507
540
  const q7 = buildWhereSQLQuery(
508
- sqliteDialect, "id",
541
+ sqliteDialect,
542
+ "id",
509
543
  [{ t: "where", path: "name", op: "startsWith", value: "Al" }],
510
- "test_people", {}
544
+ "test_people",
545
+ {}
511
546
  )
512
547
  const r7 = query(db, q7.sql, q7.params)
513
548
  expect(r7.length).toBe(1)
@@ -515,15 +550,18 @@ describe("SQL Store (SQLite integration)", () => {
515
550
 
516
551
  // Test includes (array)
517
552
  const q8 = buildWhereSQLQuery(
518
- sqliteDialect, "id",
553
+ sqliteDialect,
554
+ "id",
519
555
  [{ t: "where", path: "tags", op: "includes", value: "admin" }],
520
- "test_people", {}
556
+ "test_people",
557
+ {}
521
558
  )
522
559
  expect(query(db, q8.sql, q8.params).length).toBe(2) // Alice, Charlie
523
560
 
524
561
  // Test nested scope: where name = Alice OR (age > 30 AND name contains 'ar')
525
562
  const q9 = buildWhereSQLQuery(
526
- sqliteDialect, "id",
563
+ sqliteDialect,
564
+ "id",
527
565
  [
528
566
  { t: "where", path: "name", op: "eq", value: "Alice" },
529
567
  {
@@ -535,19 +573,204 @@ describe("SQL Store (SQLite integration)", () => {
535
573
  relation: "some"
536
574
  }
537
575
  ],
538
- "test_people", {}
576
+ "test_people",
577
+ {}
539
578
  )
540
579
  expect(query(db, q9.sql, q9.params).length).toBe(2) // Alice + Charlie
541
580
 
542
581
  // Test order + limit
543
582
  const q10 = buildWhereSQLQuery(
544
- sqliteDialect, "id", [], "test_people", {},
583
+ sqliteDialect,
584
+ "id",
585
+ [],
586
+ "test_people",
587
+ {},
545
588
  undefined,
546
589
  [{ key: "age", direction: "DESC" }] as any,
547
- undefined, 2
590
+ undefined,
591
+ 2
548
592
  )
549
593
  const r10 = query(db, q10.sql, q10.params)
550
594
  expect(r10.length).toBe(2)
551
595
  expect((JSON.parse((r10[0] as any).data) as any).name).toBe("Charlie") // oldest first
552
596
  }))
597
+
598
+ it("namespace param is in correct position for SQLite positional placeholders", () =>
599
+ withDb((db) => {
600
+ db.exec(
601
+ `CREATE TABLE IF NOT EXISTS "test_ns" (id TEXT NOT NULL, _namespace TEXT NOT NULL DEFAULT 'primary', _etag TEXT, data JSON NOT NULL, PRIMARY KEY (id, _namespace))`
602
+ )
603
+ const insert = db.prepare(
604
+ `INSERT INTO "test_ns" (id, _namespace, _etag, data) VALUES (?, ?, ?, ?)`
605
+ )
606
+ insert.run("1", "primary", "e1", JSON.stringify({ name: "Alice", role: "admin" }))
607
+ insert.run("2", "primary", "e2", JSON.stringify({ name: "Bob", role: "user" }))
608
+ insert.run("3", "other", "e3", JSON.stringify({ name: "Charlie", role: "admin" }))
609
+
610
+ // Build a filter query: role != 'deleted'
611
+ const q = buildWhereSQLQuery(
612
+ sqliteDialect,
613
+ "id",
614
+ [{ t: "where", path: "role", op: "neq", value: "deleted" }],
615
+ "test_ns",
616
+ {}
617
+ )
618
+
619
+ // Simulate what SQL.ts does: prepend _namespace = ? and put ns FIRST in params
620
+ const hasWhere = q.sql.includes("WHERE")
621
+ const nsSql = hasWhere
622
+ ? q.sql.replace("WHERE", `WHERE _namespace = ? AND`)
623
+ : q.sql.replace(`FROM "test_ns"`, `FROM "test_ns" WHERE _namespace = ?`)
624
+ const params = ["primary", ...q.params]
625
+
626
+ const results = query(db, nsSql, params)
627
+ // Should only get Alice and Bob (primary namespace), not Charlie (other namespace)
628
+ expect(results.length).toBe(2)
629
+ const names = results.map((r) => (JSON.parse((r as any).data) as any).name).sort()
630
+ expect(names).toEqual(["Alice", "Bob"])
631
+ }))
632
+ })
633
+
634
+ // --- toRow stripping and parseRow reconstruction tests ---
635
+
636
+ describe("toRow strips _etag and id from data", () => {
637
+ // Replicate the toRow logic from SQL.ts to test in isolation
638
+ const toRow = <IdKey extends PropertyKey>(e: any, idKey: IdKey) => {
639
+ const newE = makeETag(e)
640
+ const id = newE[idKey] as string
641
+ const { _etag, [idKey]: _id, ...rest } = newE as any
642
+ const data = JSON.stringify(rest)
643
+ return { id, _etag: newE._etag!, data, item: newE }
644
+ }
645
+
646
+ it("data JSON does not contain _etag", () => {
647
+ const row = toRow({ id: "1", _etag: undefined, name: "Alice", age: 30 }, "id")
648
+ const parsed = JSON.parse(row.data) as any
649
+ expect(parsed).not.toHaveProperty("_etag")
650
+ expect(parsed.name).toBe("Alice")
651
+ expect(parsed.age).toBe(30)
652
+ })
653
+
654
+ it("data JSON does not contain id field", () => {
655
+ const row = toRow({ id: "1", _etag: undefined, name: "Alice" }, "id")
656
+ const parsed = JSON.parse(row.data) as any
657
+ expect(parsed).not.toHaveProperty("id")
658
+ expect(parsed.name).toBe("Alice")
659
+ })
660
+
661
+ it("data JSON does not contain custom idKey field", () => {
662
+ const row = toRow({ myId: "abc", _etag: undefined, name: "Bob" }, "myId")
663
+ const parsed = JSON.parse(row.data) as any
664
+ expect(parsed).not.toHaveProperty("myId")
665
+ expect(parsed.name).toBe("Bob")
666
+ expect(row.id).toBe("abc")
667
+ })
668
+
669
+ it("id and _etag are returned as separate fields", () => {
670
+ const row = toRow({ id: "1", _etag: undefined, name: "Alice" }, "id")
671
+ expect(row.id).toBe("1")
672
+ expect(typeof row._etag).toBe("string")
673
+ expect(row._etag.length).toBeGreaterThan(0)
674
+ })
675
+
676
+ it("item still contains all fields including _etag and id", () => {
677
+ const row = toRow({ id: "1", _etag: undefined, name: "Alice" }, "id")
678
+ expect(row.item.id).toBe("1")
679
+ expect(row.item._etag).toBe(row._etag)
680
+ expect(row.item.name).toBe("Alice")
681
+ })
682
+
683
+ it("preserves nested objects in data", () => {
684
+ const row = toRow({ id: "1", _etag: undefined, address: { city: "NYC", zip: "10001" } }, "id")
685
+ const parsed = JSON.parse(row.data) as any
686
+ expect(parsed.address).toEqual({ city: "NYC", zip: "10001" })
687
+ expect(parsed).not.toHaveProperty("id")
688
+ expect(parsed).not.toHaveProperty("_etag")
689
+ })
690
+ })
691
+
692
+ describe("parseRow reconstructs full object from row", () => {
693
+ it("re-injects id from row column using idKey", () => {
694
+ const result: any = parseRow(
695
+ { id: "42", _etag: "etag1", data: JSON.stringify({ name: "Alice", age: 30 }) },
696
+ "id",
697
+ {}
698
+ )
699
+ expect(result.id).toBe("42")
700
+ expect(result.name).toBe("Alice")
701
+ expect(result.age).toBe(30)
702
+ expect(result._etag).toBe("etag1")
703
+ })
704
+
705
+ it("re-injects custom idKey from row column", () => {
706
+ const result: any = parseRow(
707
+ { id: "abc", _etag: "etag2", data: JSON.stringify({ name: "Bob" }) },
708
+ "myId",
709
+ {}
710
+ )
711
+ expect(result.myId).toBe("abc")
712
+ expect(result.name).toBe("Bob")
713
+ expect(result._etag).toBe("etag2")
714
+ })
715
+
716
+ it("uses _etag from row column, not from data", () => {
717
+ const result: any = parseRow(
718
+ { id: "1", _etag: "column_etag", data: JSON.stringify({ _etag: "stale_data_etag", name: "Alice" }) },
719
+ "id",
720
+ {}
721
+ )
722
+ expect(result._etag).toBe("column_etag")
723
+ })
724
+
725
+ it("uses id from row column, not from data", () => {
726
+ const result: any = parseRow(
727
+ { id: "correct_id", _etag: "e1", data: JSON.stringify({ id: "wrong_id", name: "Alice" }) },
728
+ "id",
729
+ {}
730
+ )
731
+ expect(result.id).toBe("correct_id")
732
+ })
733
+
734
+ it("applies defaultValues for missing fields", () => {
735
+ const result: any = parseRow(
736
+ { id: "1", _etag: "e1", data: JSON.stringify({ name: "Alice" }) },
737
+ "id",
738
+ { status: "active", role: "user" }
739
+ )
740
+ expect(result.name).toBe("Alice")
741
+ expect(result.status).toBe("active")
742
+ expect(result.role).toBe("user")
743
+ })
744
+
745
+ it("data fields override defaultValues", () => {
746
+ const result: any = parseRow(
747
+ { id: "1", _etag: "e1", data: JSON.stringify({ name: "Alice", status: "inactive" }) },
748
+ "id",
749
+ { status: "active" }
750
+ )
751
+ expect(result.status).toBe("inactive")
752
+ })
753
+
754
+ it("handles null _etag from row", () => {
755
+ const result: any = parseRow(
756
+ { id: "1", _etag: null, data: JSON.stringify({ name: "Alice" }) },
757
+ "id",
758
+ {}
759
+ )
760
+ expect(result._etag).toBeUndefined()
761
+ })
762
+
763
+ it("round-trip: toRow then parseRow reconstructs the original", () => {
764
+ const original = { id: "1", _etag: undefined as string | undefined, name: "Alice", age: 30, tags: ["admin"] }
765
+ const newE = makeETag(original)
766
+ const { _etag, id: _id, ...rest } = newE as any
767
+ const row = { id: newE.id, _etag: newE._etag!, data: JSON.stringify(rest) }
768
+
769
+ const reconstructed: any = parseRow(row, "id", {})
770
+ expect(reconstructed.id).toBe("1")
771
+ expect(reconstructed.name).toBe("Alice")
772
+ expect(reconstructed.age).toBe(30)
773
+ expect(reconstructed.tags).toEqual(["admin"])
774
+ expect(reconstructed._etag).toBe(newE._etag)
775
+ })
553
776
  })