orez 0.1.6 → 0.1.7

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 (53) hide show
  1. package/README.md +185 -225
  2. package/dist/admin/log-store.d.ts.map +1 -1
  3. package/dist/admin/log-store.js +17 -6
  4. package/dist/admin/log-store.js.map +1 -1
  5. package/dist/admin/server.d.ts +1 -0
  6. package/dist/admin/server.d.ts.map +1 -1
  7. package/dist/admin/server.js +10 -0
  8. package/dist/admin/server.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +89 -45
  11. package/dist/cli.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +104 -17
  15. package/dist/index.js.map +1 -1
  16. package/dist/integration/test-permissions.d.ts +5 -0
  17. package/dist/integration/test-permissions.d.ts.map +1 -0
  18. package/dist/integration/test-permissions.js +89 -0
  19. package/dist/integration/test-permissions.js.map +1 -0
  20. package/dist/pg-proxy.js +2 -2
  21. package/dist/pg-proxy.js.map +1 -1
  22. package/dist/replication/change-tracker.d.ts.map +1 -1
  23. package/dist/replication/change-tracker.js +15 -13
  24. package/dist/replication/change-tracker.js.map +1 -1
  25. package/dist/replication/handler.d.ts.map +1 -1
  26. package/dist/replication/handler.js +27 -2
  27. package/dist/replication/handler.js.map +1 -1
  28. package/dist/sqlite-mode/index.d.ts +1 -0
  29. package/dist/sqlite-mode/index.d.ts.map +1 -1
  30. package/dist/sqlite-mode/index.js +1 -0
  31. package/dist/sqlite-mode/index.js.map +1 -1
  32. package/dist/sqlite-mode/native-binary.d.ts +11 -0
  33. package/dist/sqlite-mode/native-binary.d.ts.map +1 -0
  34. package/dist/sqlite-mode/native-binary.js +67 -0
  35. package/dist/sqlite-mode/native-binary.js.map +1 -0
  36. package/package.json +8 -2
  37. package/src/admin/log-store.ts +19 -9
  38. package/src/admin/server.ts +12 -0
  39. package/src/cli.ts +92 -43
  40. package/src/index.ts +117 -18
  41. package/src/integration/integration.test.ts +86 -15
  42. package/src/integration/native-binary.guard.test.ts +13 -0
  43. package/src/integration/native-startup.test.ts +44 -0
  44. package/src/integration/restore-live-stress.test.ts +437 -0
  45. package/src/integration/restore-reset.test.ts +135 -16
  46. package/src/integration/test-permissions.ts +111 -0
  47. package/src/pg-proxy.ts +2 -2
  48. package/src/replication/change-tracker.test.ts +1 -1
  49. package/src/replication/change-tracker.ts +16 -13
  50. package/src/replication/handler.test.ts +2 -2
  51. package/src/replication/handler.ts +30 -2
  52. package/src/sqlite-mode/index.ts +1 -0
  53. package/src/sqlite-mode/native-binary.ts +89 -0
@@ -0,0 +1,111 @@
1
+ import type { PGlite } from '@electric-sql/pglite'
2
+
3
+ type DbLike = Pick<PGlite, 'query' | 'exec'>
4
+
5
+ const ALLOW_ALL_CONDITION = { type: 'and', conditions: [] as unknown[] }
6
+ const ALLOW_ALL_POLICY = [['allow', ALLOW_ALL_CONDITION]]
7
+ const DEFAULT_APP_ID = process.env.ZERO_APP_ID?.trim() || 'zero'
8
+
9
+ export async function installAllowAllPermissions(
10
+ db: DbLike,
11
+ tables: string[]
12
+ ): Promise<void> {
13
+ const schemas = await findPermissionsSchemas(db)
14
+ if (schemas.length === 0) {
15
+ schemas.push(DEFAULT_APP_ID)
16
+ }
17
+
18
+ for (const schema of schemas) {
19
+ const quotedSchema = '"' + schema.replace(/"/g, '""') + '"'
20
+
21
+ // Bootstrap the same global permissions table shape zero-cache expects.
22
+ await db.exec(`
23
+ CREATE SCHEMA IF NOT EXISTS ${quotedSchema};
24
+
25
+ CREATE TABLE IF NOT EXISTS ${quotedSchema}.permissions (
26
+ "permissions" JSONB,
27
+ "hash" TEXT,
28
+ "lock" BOOL PRIMARY KEY DEFAULT true CHECK (lock)
29
+ );
30
+
31
+ CREATE OR REPLACE FUNCTION ${quotedSchema}.set_permissions_hash()
32
+ RETURNS TRIGGER AS $$
33
+ BEGIN
34
+ NEW.hash = md5(NEW.permissions::text);
35
+ RETURN NEW;
36
+ END;
37
+ $$ LANGUAGE plpgsql;
38
+
39
+ DROP TRIGGER IF EXISTS on_set_permissions ON ${quotedSchema}.permissions;
40
+ CREATE TRIGGER on_set_permissions
41
+ BEFORE INSERT OR UPDATE ON ${quotedSchema}.permissions
42
+ FOR EACH ROW
43
+ EXECUTE FUNCTION ${quotedSchema}.set_permissions_hash();
44
+
45
+ INSERT INTO ${quotedSchema}.permissions ("permissions")
46
+ VALUES (NULL)
47
+ ON CONFLICT DO NOTHING;
48
+ `)
49
+
50
+ const existing = await db.query<{ permissions: unknown }>(
51
+ `SELECT permissions FROM ${quotedSchema}.permissions WHERE lock = true LIMIT 1`
52
+ )
53
+ const existingPermissions = parsePermissions(existing.rows[0]?.permissions)
54
+
55
+ const tablesToAdd = Object.fromEntries(
56
+ tables.map((table) => [
57
+ table,
58
+ {
59
+ row: {
60
+ select: ALLOW_ALL_POLICY,
61
+ insert: ALLOW_ALL_POLICY,
62
+ update: {
63
+ preMutation: ALLOW_ALL_POLICY,
64
+ postMutation: ALLOW_ALL_POLICY,
65
+ },
66
+ delete: ALLOW_ALL_POLICY,
67
+ },
68
+ },
69
+ ])
70
+ )
71
+
72
+ const permissions = {
73
+ ...existingPermissions,
74
+ tables: {
75
+ ...(existingPermissions.tables || {}),
76
+ ...tablesToAdd,
77
+ },
78
+ }
79
+
80
+ await db.query(
81
+ `UPDATE ${quotedSchema}.permissions SET permissions = $1 WHERE lock = true`,
82
+ [JSON.stringify(permissions)]
83
+ )
84
+ }
85
+ }
86
+
87
+ function parsePermissions(value: unknown): { tables?: Record<string, unknown> } {
88
+ if (!value) return {}
89
+ if (typeof value === 'string') {
90
+ try {
91
+ return JSON.parse(value)
92
+ } catch {
93
+ return {}
94
+ }
95
+ }
96
+ if (typeof value === 'object') return value as { tables?: Record<string, unknown> }
97
+ return {}
98
+ }
99
+
100
+ async function findPermissionsSchemas(db: DbLike): Promise<string[]> {
101
+ const result = await db.query<{ schemaname: string }>(
102
+ `SELECT schemaname
103
+ FROM pg_tables
104
+ WHERE tablename = 'permissions'
105
+ AND schemaname NOT IN ('pg_catalog', 'information_schema')
106
+ AND schemaname NOT LIKE 'pg_%'
107
+ ORDER BY CASE WHEN schemaname = $1 THEN 0 ELSE 1 END, schemaname`,
108
+ [DEFAULT_APP_ID]
109
+ )
110
+ return result.rows.map((r) => r.schemaname)
111
+ }
package/src/pg-proxy.ts CHANGED
@@ -57,10 +57,10 @@ const QUERY_REWRITES: Array<{ match: RegExp; replace: string }> = [
57
57
  match: /\bSET\s+TRANSACTION\s*;/gi,
58
58
  replace: ';',
59
59
  },
60
- // redirect pg_replication_slots to our fake table
60
+ // redirect pg_replication_slots to our fake table in _orez schema
61
61
  {
62
62
  match: /\bpg_replication_slots\b/g,
63
- replace: 'public._zero_replication_slots',
63
+ replace: '_orez._zero_replication_slots',
64
64
  },
65
65
  ]
66
66
 
@@ -150,7 +150,7 @@ describe('change-tracker', () => {
150
150
  try {
151
151
  await db.exec(`CREATE PUBLICATION "zero_scope"`)
152
152
  await installChangeTracking(db) // reinstall picks up publication scope
153
- await db.exec(`TRUNCATE public._zero_changes`)
153
+ await db.exec(`TRUNCATE _orez._zero_changes`)
154
154
 
155
155
  await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', 1)`)
156
156
  const changes = await getChangesSince(db, 0)
@@ -13,13 +13,16 @@ export interface ChangeRecord {
13
13
  }
14
14
 
15
15
  export async function installChangeTracking(db: PGlite): Promise<void> {
16
+ // use _orez schema for internal tables - survives pg_restore of public schema
17
+ await db.exec(`CREATE SCHEMA IF NOT EXISTS _orez`)
18
+
16
19
  // create changes table and watermark sequence
17
20
  await db.exec(`
18
- CREATE SEQUENCE IF NOT EXISTS public._zero_watermark;
21
+ CREATE SEQUENCE IF NOT EXISTS _orez._zero_watermark;
19
22
 
20
- CREATE TABLE IF NOT EXISTS public._zero_changes (
23
+ CREATE TABLE IF NOT EXISTS _orez._zero_changes (
21
24
  id BIGSERIAL PRIMARY KEY,
22
- watermark BIGINT NOT NULL DEFAULT nextval('public._zero_watermark'),
25
+ watermark BIGINT NOT NULL DEFAULT nextval('_orez._zero_watermark'),
23
26
  table_name TEXT NOT NULL,
24
27
  op TEXT NOT NULL,
25
28
  row_data JSONB,
@@ -27,9 +30,9 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
27
30
  changed_at TIMESTAMPTZ DEFAULT NOW()
28
31
  );
29
32
 
30
- CREATE INDEX IF NOT EXISTS _zero_changes_watermark_idx ON public._zero_changes (watermark);
33
+ CREATE INDEX IF NOT EXISTS _zero_changes_watermark_idx ON _orez._zero_changes (watermark);
31
34
 
32
- CREATE TABLE IF NOT EXISTS public._zero_replication_slots (
35
+ CREATE TABLE IF NOT EXISTS _orez._zero_replication_slots (
33
36
  slot_name TEXT PRIMARY KEY,
34
37
  restart_lsn TEXT NOT NULL DEFAULT '0/1000000',
35
38
  confirmed_flush_lsn TEXT NOT NULL DEFAULT '0/1000000',
@@ -42,7 +45,7 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
42
45
  );
43
46
  `)
44
47
 
45
- // create trigger function
48
+ // create trigger function (writes to _orez schema)
46
49
  await db.exec(`
47
50
  CREATE OR REPLACE FUNCTION public._zero_track_change() RETURNS TRIGGER AS $$
48
51
  DECLARE
@@ -50,15 +53,15 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
50
53
  BEGIN
51
54
  qualified_name := TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME;
52
55
  IF TG_OP = 'DELETE' THEN
53
- INSERT INTO public._zero_changes (table_name, op, old_data)
56
+ INSERT INTO _orez._zero_changes (table_name, op, old_data)
54
57
  VALUES (qualified_name, 'DELETE', row_to_json(OLD)::jsonb);
55
58
  RETURN OLD;
56
59
  ELSIF TG_OP = 'UPDATE' THEN
57
- INSERT INTO public._zero_changes (table_name, op, row_data, old_data)
60
+ INSERT INTO _orez._zero_changes (table_name, op, row_data, old_data)
58
61
  VALUES (qualified_name, 'UPDATE', row_to_json(NEW)::jsonb, row_to_json(OLD)::jsonb);
59
62
  RETURN NEW;
60
63
  ELSIF TG_OP = 'INSERT' THEN
61
- INSERT INTO public._zero_changes (table_name, op, row_data)
64
+ INSERT INTO _orez._zero_changes (table_name, op, row_data)
62
65
  VALUES (qualified_name, 'INSERT', row_to_json(NEW)::jsonb);
63
66
  RETURN NEW;
64
67
  END IF;
@@ -98,7 +101,7 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
98
101
  const all = await db.query<{ tablename: string }>(
99
102
  `SELECT tablename FROM pg_tables
100
103
  WHERE schemaname = 'public'
101
- AND tablename NOT IN ('migrations', '_zero_changes')
104
+ AND tablename NOT IN ('migrations')
102
105
  AND tablename NOT LIKE '_zero_%'`
103
106
  )
104
107
  tables = all.rows
@@ -208,7 +211,7 @@ export async function getChangesSince(
208
211
  limit = 1000
209
212
  ): Promise<ChangeRecord[]> {
210
213
  const result = await db.query<ChangeRecord>(
211
- 'SELECT * FROM public._zero_changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
214
+ 'SELECT * FROM _orez._zero_changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
212
215
  [watermark, limit]
213
216
  )
214
217
  return result.rows
@@ -219,7 +222,7 @@ export async function purgeConsumedChanges(
219
222
  watermark: number
220
223
  ): Promise<number> {
221
224
  const result = await db.query<{ count: string }>(
222
- 'WITH deleted AS (DELETE FROM public._zero_changes WHERE watermark <= $1 RETURNING 1) SELECT count(*)::text AS count FROM deleted',
225
+ 'WITH deleted AS (DELETE FROM _orez._zero_changes WHERE watermark <= $1 RETURNING 1) SELECT count(*)::text AS count FROM deleted',
223
226
  [watermark]
224
227
  )
225
228
  return Number(result.rows[0]?.count || 0)
@@ -227,7 +230,7 @@ export async function purgeConsumedChanges(
227
230
 
228
231
  export async function getCurrentWatermark(db: PGlite): Promise<number> {
229
232
  const result = await db.query<{ last_value: string; is_called: boolean }>(
230
- 'SELECT last_value, is_called FROM public._zero_watermark'
233
+ 'SELECT last_value, is_called FROM _orez._zero_watermark'
231
234
  )
232
235
  const { last_value, is_called } = result.rows[0]
233
236
  if (!is_called) return 0
@@ -74,7 +74,7 @@ describe('handleReplicationQuery', () => {
74
74
  expect(parsed!.values[3]).toBe('pgoutput')
75
75
 
76
76
  const slots = await db.query<{ slot_name: string }>(
77
- `SELECT slot_name FROM public._zero_replication_slots WHERE slot_name = 'test_slot'`
77
+ `SELECT slot_name FROM _orez._zero_replication_slots WHERE slot_name = 'test_slot'`
78
78
  )
79
79
  expect(slots.rows).toHaveLength(1)
80
80
  })
@@ -87,7 +87,7 @@ describe('handleReplicationQuery', () => {
87
87
  await handleReplicationQuery('DROP_REPLICATION_SLOT "drop_me"', db)
88
88
 
89
89
  const slots = await db.query<{ count: string }>(
90
- `SELECT count(*) as count FROM public._zero_replication_slots WHERE slot_name = 'drop_me'`
90
+ `SELECT count(*) as count FROM _orez._zero_replication_slots WHERE slot_name = 'drop_me'`
91
91
  )
92
92
  expect(Number(slots.rows[0].count)).toBe(0)
93
93
  })
@@ -210,7 +210,7 @@ export async function handleReplicationQuery(
210
210
 
211
211
  // persist slot so pg_replication_slots queries find it
212
212
  await db.query(
213
- `INSERT INTO public._zero_replication_slots (slot_name, restart_lsn, confirmed_flush_lsn)
213
+ `INSERT INTO _orez._zero_replication_slots (slot_name, restart_lsn, confirmed_flush_lsn)
214
214
  VALUES ($1, $2, $2)
215
215
  ON CONFLICT (slot_name) DO UPDATE SET restart_lsn = $2, confirmed_flush_lsn = $2`,
216
216
  [slotName, lsn]
@@ -226,7 +226,7 @@ export async function handleReplicationQuery(
226
226
  const match = trimmed.match(/DROP_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i)
227
227
  const slotName = match?.[1] || match?.[2] || match?.[3]
228
228
  if (slotName) {
229
- await db.query(`DELETE FROM public._zero_replication_slots WHERE slot_name = $1`, [
229
+ await db.query(`DELETE FROM _orez._zero_replication_slots WHERE slot_name = $1`, [
230
230
  slotName,
231
231
  ])
232
232
  }
@@ -612,6 +612,17 @@ async function streamChanges(
612
612
  }
613
613
  }
614
614
 
615
+ // zero-cache expects specific camel-cased keys in shard clients rows.
616
+ // Some upstream paths can surface lower-cased variants; normalize them.
617
+ if (schema !== 'public' && tableName === 'clients') {
618
+ const sample = rowData || oldData
619
+ if (sample) {
620
+ log.debug.proxy(`shard clients keys: ${Object.keys(sample).join(', ')}`)
621
+ }
622
+ rowData = normalizeShardClientsRow(rowData)
623
+ oldData = normalizeShardClientsRow(oldData)
624
+ }
625
+
615
626
  const row = rowData || oldData
616
627
  if (!row) continue
617
628
 
@@ -658,4 +669,21 @@ async function streamChanges(
658
669
  writer.write(wrapCopyData(commitMsg))
659
670
  }
660
671
 
672
+ function normalizeShardClientsRow(
673
+ row: Record<string, unknown> | null
674
+ ): Record<string, unknown> | null {
675
+ if (!row) return row
676
+ const out: Record<string, unknown> = { ...row }
677
+ if (out.clientGroupID === undefined && out.clientgroupid !== undefined) {
678
+ out.clientGroupID = out.clientgroupid
679
+ }
680
+ if (out.clientID === undefined && out.clientid !== undefined) {
681
+ out.clientID = out.clientid
682
+ }
683
+ if (out.lastMutationID === undefined && out.lastmutationid !== undefined) {
684
+ out.lastMutationID = out.lastmutationid
685
+ }
686
+ return out
687
+ }
688
+
661
689
  export { buildErrorResponse }
@@ -12,3 +12,4 @@ export * from './types.js'
12
12
  export * from './resolve-mode.js'
13
13
  export * from './apply-mode.js'
14
14
  export * from './shim-template.js'
15
+ export * from './native-binary.js'
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+
4
+ import { resolvePackage } from './resolve-mode.js'
5
+
6
+ const NATIVE_BINARY_RELATIVE_PATHS = ['build/Release/better_sqlite3.node']
7
+
8
+ export interface NativeBinaryCheckResult {
9
+ packageEntryPath: string
10
+ packageRoot: string
11
+ expectedPaths: string[]
12
+ existingPaths: string[]
13
+ found: boolean
14
+ }
15
+
16
+ function findPackageRoot(entryPath: string): string {
17
+ if (!entryPath) return ''
18
+
19
+ let dir = dirname(entryPath)
20
+ for (let i = 0; i < 12; i++) {
21
+ const pkgJson = resolve(dir, 'package.json')
22
+ if (existsSync(pkgJson)) {
23
+ try {
24
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'))
25
+ if (pkg?.name === '@rocicorp/zero-sqlite3') {
26
+ return dir
27
+ }
28
+ } catch {
29
+ // ignore malformed package.json and continue searching upward
30
+ }
31
+ }
32
+
33
+ const parent = dirname(dir)
34
+ if (parent === dir) break
35
+ dir = parent
36
+ }
37
+
38
+ return ''
39
+ }
40
+
41
+ export function inspectNativeSqliteBinary(): NativeBinaryCheckResult {
42
+ const packageEntryPath = resolvePackage('@rocicorp/zero-sqlite3')
43
+ const packageRoot = findPackageRoot(packageEntryPath)
44
+ const expectedPaths = packageRoot
45
+ ? NATIVE_BINARY_RELATIVE_PATHS.map((relativePath) =>
46
+ resolve(packageRoot, relativePath)
47
+ )
48
+ : []
49
+ const existingPaths = expectedPaths.filter((filePath) => existsSync(filePath))
50
+
51
+ return {
52
+ packageEntryPath,
53
+ packageRoot,
54
+ expectedPaths,
55
+ existingPaths,
56
+ found: existingPaths.length > 0,
57
+ }
58
+ }
59
+
60
+ export function hasMissingNativeBinarySignature(message: string): boolean {
61
+ const text = message.toLowerCase()
62
+ return (
63
+ text.includes('better_sqlite3.node') ||
64
+ (text.includes('could not locate the bindings file') &&
65
+ text.includes('zero-sqlite3')) ||
66
+ (text.includes('err_dlopen_failed') && text.includes('better_sqlite3')) ||
67
+ (text.includes('no native build was found') && text.includes('sqlite'))
68
+ )
69
+ }
70
+
71
+ export function formatNativeBootstrapInstructions(
72
+ result: NativeBinaryCheckResult
73
+ ): string {
74
+ const expectedList =
75
+ result.expectedPaths.length > 0
76
+ ? result.expectedPaths.map((filePath) => ` - ${filePath}`).join('\n')
77
+ : ' - <unable to resolve @rocicorp/zero-sqlite3 package root>'
78
+
79
+ return [
80
+ 'native sqlite binary is missing.',
81
+ `resolved package entry: ${result.packageEntryPath || '<not resolved>'}`,
82
+ 'expected native binary path(s):',
83
+ expectedList,
84
+ 'fix:',
85
+ ' bun i @rocicorp/zero-sqlite3',
86
+ ' bun run native:bootstrap',
87
+ 'manual emergency fallback (not automated): copy a known-good better_sqlite3.node into build/Release and re-run native:bootstrap.',
88
+ ].join('\n')
89
+ }