@syncular/client-plugin-encryption 0.0.1 → 0.0.2-126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @syncular/client-plugin-encryption
2
+
3
+ End-to-end encryption plugin for the Syncular client. Supports field-level encryption with key sharing between devices.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @syncular/client-plugin-encryption
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import {
15
+ createFieldEncryptionPlugin,
16
+ createStaticFieldEncryptionKeys,
17
+ } from '@syncular/client-plugin-encryption';
18
+
19
+ const encryption = createFieldEncryptionPlugin({
20
+ rules: [{ scope: 'user', table: 'notes', fields: ['body'] }],
21
+ keys: createStaticFieldEncryptionKeys({
22
+ keys: { default: 'base64url:...' },
23
+ }),
24
+ });
25
+ ```
26
+
27
+ ## Documentation
28
+
29
+ - Encryption guide: https://syncular.dev/docs/build/encryption
30
+
31
+ ## Links
32
+
33
+ - GitHub: https://github.com/syncular/syncular
34
+ - Issues: https://github.com/syncular/syncular/issues
35
+
36
+ > Status: Alpha. APIs and storage layouts may change between releases.
@@ -0,0 +1,7 @@
1
+ export declare function randomBytes(length: number): Uint8Array;
2
+ export declare function bytesToBase64(bytes: Uint8Array): string;
3
+ export declare function base64ToBytes(base64: string): Uint8Array;
4
+ export declare function bytesToBase64Url(bytes: Uint8Array): string;
5
+ export declare function base64UrlToBytes(base64url: string): Uint8Array;
6
+ export declare function hexToBytes(hex: string): Uint8Array;
7
+ //# sourceMappingURL=crypto-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto-utils.d.ts","sourceRoot":"","sources":["../src/crypto-utils.ts"],"names":[],"mappings":"AAYA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAWtD;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAqCvD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CA8BxD;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAK1D;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,CAO9D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAYlD"}
@@ -0,0 +1,110 @@
1
+ const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
2
+ const BASE64_LOOKUP = new Uint8Array(256);
3
+ for (let i = 0; i < BASE64_CHARS.length; i++) {
4
+ BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
5
+ }
6
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
7
+ const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]*$/;
8
+ export function randomBytes(length) {
9
+ const cryptoObj = globalThis.crypto;
10
+ if (!cryptoObj?.getRandomValues) {
11
+ throw new Error('Secure random generator is not available (crypto.getRandomValues). ' +
12
+ 'Ensure you are running in a secure context or polyfill crypto.');
13
+ }
14
+ const out = new Uint8Array(length);
15
+ cryptoObj.getRandomValues(out);
16
+ return out;
17
+ }
18
+ export function bytesToBase64(bytes) {
19
+ if (typeof Buffer !== 'undefined') {
20
+ return Buffer.from(bytes).toString('base64');
21
+ }
22
+ let result = '';
23
+ const len = bytes.length;
24
+ const remainder = len % 3;
25
+ for (let i = 0; i < len - remainder; i += 3) {
26
+ const a = bytes[i];
27
+ const b = bytes[i + 1];
28
+ const c = bytes[i + 2];
29
+ result +=
30
+ BASE64_CHARS.charAt((a >> 2) & 0x3f) +
31
+ BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
32
+ BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
33
+ BASE64_CHARS.charAt(c & 0x3f);
34
+ }
35
+ if (remainder === 1) {
36
+ const a = bytes[len - 1];
37
+ result +=
38
+ BASE64_CHARS.charAt((a >> 2) & 0x3f) +
39
+ BASE64_CHARS.charAt((a << 4) & 0x3f) +
40
+ '==';
41
+ }
42
+ else if (remainder === 2) {
43
+ const a = bytes[len - 2];
44
+ const b = bytes[len - 1];
45
+ result +=
46
+ BASE64_CHARS.charAt((a >> 2) & 0x3f) +
47
+ BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
48
+ BASE64_CHARS.charAt((b << 2) & 0x3f) +
49
+ '=';
50
+ }
51
+ return result;
52
+ }
53
+ export function base64ToBytes(base64) {
54
+ if (!BASE64_PATTERN.test(base64)) {
55
+ throw new Error('Invalid base64 string');
56
+ }
57
+ if (typeof Buffer !== 'undefined') {
58
+ return new Uint8Array(Buffer.from(base64, 'base64'));
59
+ }
60
+ const len = base64.length;
61
+ let padding = 0;
62
+ if (base64[len - 1] === '=')
63
+ padding++;
64
+ if (base64[len - 2] === '=')
65
+ padding++;
66
+ const outputLen = (len * 3) / 4 - padding;
67
+ const out = new Uint8Array(outputLen);
68
+ let outIdx = 0;
69
+ for (let i = 0; i < len; i += 4) {
70
+ const a = BASE64_LOOKUP[base64.charCodeAt(i)];
71
+ const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)];
72
+ const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)];
73
+ const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)];
74
+ out[outIdx++] = (a << 2) | (b >> 4);
75
+ if (outIdx < outputLen)
76
+ out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
77
+ if (outIdx < outputLen)
78
+ out[outIdx++] = ((c << 6) | d) & 0xff;
79
+ }
80
+ return out;
81
+ }
82
+ export function bytesToBase64Url(bytes) {
83
+ return bytesToBase64(bytes)
84
+ .replace(/\+/g, '-')
85
+ .replace(/\//g, '_')
86
+ .replace(/=+$/g, '');
87
+ }
88
+ export function base64UrlToBytes(base64url) {
89
+ if (!BASE64_URL_PATTERN.test(base64url)) {
90
+ throw new Error('Invalid base64url string');
91
+ }
92
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
93
+ const padded = base64 + '==='.slice((base64.length + 3) % 4);
94
+ return base64ToBytes(padded);
95
+ }
96
+ export function hexToBytes(hex) {
97
+ const normalized = hex.trim().toLowerCase();
98
+ if (normalized.length % 2 !== 0) {
99
+ throw new Error('Invalid hex string (length must be even)');
100
+ }
101
+ const out = new Uint8Array(normalized.length / 2);
102
+ for (let i = 0; i < out.length; i++) {
103
+ const byte = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
104
+ if (!Number.isFinite(byte))
105
+ throw new Error('Invalid hex string');
106
+ out[i] = byte;
107
+ }
108
+ return out;
109
+ }
110
+ //# sourceMappingURL=crypto-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto-utils.js","sourceRoot":"","sources":["../src/crypto-utils.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { SyncClientDb, SyncClientPlugin, SyncClientPluginContext } from '@syncular/client';
1
+ import type { SyncClientDb, SyncClientPlugin, SyncClientPluginContext, SyncEngine } from '@syncular/client';
2
2
  import { type Kysely } from 'kysely';
3
3
  export * from './key-sharing';
4
- export type FieldDecryptionErrorMode = 'throw' | 'keepCiphertext';
5
- export interface FieldEncryptionRule {
4
+ type FieldDecryptionErrorMode = 'throw' | 'keepCiphertext';
5
+ interface FieldEncryptionRule {
6
6
  scope: string;
7
7
  /**
8
8
  * Optional table selector. Strongly recommended for correctness:
@@ -35,7 +35,7 @@ export interface FieldEncryptionKeys {
35
35
  field: string;
36
36
  }) => string | Promise<string>;
37
37
  }
38
- export interface FieldEncryptionPluginOptions {
38
+ interface FieldEncryptionPluginOptions {
39
39
  name?: string;
40
40
  rules: FieldEncryptionRule[];
41
41
  keys: FieldEncryptionKeys;
@@ -50,7 +50,7 @@ export interface FieldEncryptionPluginOptions {
50
50
  */
51
51
  envelopePrefix?: string;
52
52
  }
53
- export interface RefreshEncryptedFieldsTarget {
53
+ interface RefreshEncryptedFieldsTarget {
54
54
  scope: string;
55
55
  table: string;
56
56
  fields?: string[];
@@ -61,17 +61,9 @@ export interface RefreshEncryptedFieldsResult {
61
61
  rowsUpdated: number;
62
62
  fieldsUpdated: number;
63
63
  }
64
- export interface RefreshEncryptedFieldsOptions<DB extends SyncClientDb = SyncClientDb> {
65
- db: Kysely<DB>;
66
- rules: FieldEncryptionRule[];
67
- keys: FieldEncryptionKeys;
68
- envelopePrefix?: string;
69
- decryptionErrorMode?: FieldDecryptionErrorMode;
70
- targets?: RefreshEncryptedFieldsTarget[];
71
- ctx?: Partial<SyncClientPluginContext>;
72
- }
73
64
  export interface FieldEncryptionPluginRefreshRequest<DB extends SyncClientDb = SyncClientDb> {
74
65
  db: Kysely<DB>;
66
+ engine?: Pick<SyncEngine<DB>, 'recordLocalMutations'>;
75
67
  targets?: RefreshEncryptedFieldsTarget[];
76
68
  ctx?: Partial<SyncClientPluginContext>;
77
69
  }
@@ -82,6 +74,5 @@ export declare function createStaticFieldEncryptionKeys(args: {
82
74
  keys: Record<string, Uint8Array | string>;
83
75
  encryptionKid?: string;
84
76
  }): FieldEncryptionKeys;
85
- export declare function refreshEncryptedFields<DB extends SyncClientDb = SyncClientDb>(options: RefreshEncryptedFieldsOptions<DB>): Promise<RefreshEncryptedFieldsResult>;
86
77
  export declare function createFieldEncryptionPlugin(pluginOptions: FieldEncryptionPluginOptions): FieldEncryptionPlugin;
87
78
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,YAAY,EACZ,gBAAgB,EAChB,uBAAuB,EACvB,UAAU,EACX,MAAM,kBAAkB,CAAC;AAQ1B,OAAO,EAAE,KAAK,MAAM,EAAO,MAAM,QAAQ,CAAC;AAU1C,cAAc,eAAe,CAAC;AAI9B,KAAK,wBAAwB,GAAG,OAAO,GAAG,gBAAgB,CAAC;AAE3D,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1D;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CACjB,GAAG,EAAE,uBAAuB,EAC5B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KACjE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED,UAAU,4BAA4B;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,IAAI,EAAE,mBAAmB,CAAC;IAC1B;;;OAGG;IACH,mBAAmB,CAAC,EAAE,wBAAwB,CAAC;IAC/C;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,UAAU,4BAA4B;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,4BAA4B;IAC3C,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB;AAeD,MAAM,WAAW,mCAAmC,CAClD,EAAE,SAAS,YAAY,GAAG,YAAY;IAEtC,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,sBAAsB,CAAC,CAAC;IACtD,OAAO,CAAC,EAAE,4BAA4B,EAAE,CAAC;IACzC,GAAG,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,sBAAsB,EAAE,CAAC,EAAE,SAAS,YAAY,GAAG,YAAY,EAC7D,OAAO,EAAE,mCAAmC,CAAC,EAAE,CAAC,KAC7C,OAAO,CAAC,4BAA4B,CAAC,CAAC;CAC5C;AA0BD,wBAAgB,+BAA+B,CAAC,IAAI,EAAE;IACpD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAAC,CAAC;IAC1C,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GAAG,mBAAmB,CAsBtB;AAqjBD,wBAAgB,2BAA2B,CACzC,aAAa,EAAE,4BAA4B,GAC1C,qBAAqB,CA0MvB"}
package/dist/index.js CHANGED
@@ -1,130 +1,12 @@
1
1
  import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
2
+ import { isRecord } from '@syncular/core';
2
3
  import { sql } from 'kysely';
4
+ import { base64ToBytes, base64UrlToBytes, bytesToBase64Url, hexToBytes, randomBytes, } from './crypto-utils.js';
3
5
  // Re-export key sharing utilities
4
- export * from './key-sharing';
6
+ export * from './key-sharing.js';
5
7
  const DEFAULT_PREFIX = 'dgsync:e2ee:1:';
6
8
  const encoder = new TextEncoder();
7
9
  const decoder = new TextDecoder();
8
- // Base64 lookup tables for universal encoding/decoding (works in all runtimes)
9
- const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
10
- const BASE64_LOOKUP = new Uint8Array(256);
11
- for (let i = 0; i < BASE64_CHARS.length; i++) {
12
- BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
13
- }
14
- function isRecord(value) {
15
- return typeof value === 'object' && value !== null && !Array.isArray(value);
16
- }
17
- function randomBytes(length) {
18
- const cryptoObj = globalThis.crypto;
19
- if (!cryptoObj?.getRandomValues) {
20
- throw new Error('Secure random generator is not available (crypto.getRandomValues). ' +
21
- 'Ensure you are running in a secure context or polyfill crypto.');
22
- }
23
- const out = new Uint8Array(length);
24
- cryptoObj.getRandomValues(out);
25
- return out;
26
- }
27
- /**
28
- * Universal base64 encoding that works in all JavaScript runtimes.
29
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
30
- */
31
- function bytesToBase64(bytes) {
32
- // Node/Bun fast path
33
- if (typeof Buffer !== 'undefined') {
34
- return Buffer.from(bytes).toString('base64');
35
- }
36
- // Universal fallback using lookup table (works in RN, browsers, etc.)
37
- let result = '';
38
- const len = bytes.length;
39
- const remainder = len % 3;
40
- // Process 3 bytes at a time
41
- for (let i = 0; i < len - remainder; i += 3) {
42
- const a = bytes[i];
43
- const b = bytes[i + 1];
44
- const c = bytes[i + 2];
45
- result +=
46
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
47
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
48
- BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
49
- BASE64_CHARS.charAt(c & 0x3f);
50
- }
51
- // Handle remaining bytes
52
- if (remainder === 1) {
53
- const a = bytes[len - 1];
54
- result +=
55
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
56
- BASE64_CHARS.charAt((a << 4) & 0x3f) +
57
- '==';
58
- }
59
- else if (remainder === 2) {
60
- const a = bytes[len - 2];
61
- const b = bytes[len - 1];
62
- result +=
63
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
64
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
65
- BASE64_CHARS.charAt((b << 2) & 0x3f) +
66
- '=';
67
- }
68
- return result;
69
- }
70
- /**
71
- * Universal base64 decoding that works in all JavaScript runtimes.
72
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
73
- */
74
- function base64ToBytes(base64) {
75
- // Node/Bun fast path
76
- if (typeof Buffer !== 'undefined') {
77
- return new Uint8Array(Buffer.from(base64, 'base64'));
78
- }
79
- // Universal fallback using lookup table (works in RN, browsers, etc.)
80
- // Remove padding and calculate output length
81
- const len = base64.length;
82
- let padding = 0;
83
- if (base64[len - 1] === '=')
84
- padding++;
85
- if (base64[len - 2] === '=')
86
- padding++;
87
- const outputLen = (len * 3) / 4 - padding;
88
- const out = new Uint8Array(outputLen);
89
- let outIdx = 0;
90
- for (let i = 0; i < len; i += 4) {
91
- const a = BASE64_LOOKUP[base64.charCodeAt(i)];
92
- const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)];
93
- const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)];
94
- const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)];
95
- out[outIdx++] = (a << 2) | (b >> 4);
96
- if (outIdx < outputLen)
97
- out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
98
- if (outIdx < outputLen)
99
- out[outIdx++] = ((c << 6) | d) & 0xff;
100
- }
101
- return out;
102
- }
103
- function bytesToBase64Url(bytes) {
104
- return bytesToBase64(bytes)
105
- .replace(/\+/g, '-')
106
- .replace(/\//g, '_')
107
- .replace(/=+$/g, '');
108
- }
109
- function base64UrlToBytes(base64url) {
110
- const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
111
- const padded = base64 + '==='.slice((base64.length + 3) % 4);
112
- return base64ToBytes(padded);
113
- }
114
- function hexToBytes(hex) {
115
- const normalized = hex.trim().toLowerCase();
116
- if (normalized.length % 2 !== 0) {
117
- throw new Error('Invalid hex string (length must be even)');
118
- }
119
- const out = new Uint8Array(normalized.length / 2);
120
- for (let i = 0; i < out.length; i++) {
121
- const byte = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
122
- if (!Number.isFinite(byte))
123
- throw new Error('Invalid hex string');
124
- out[i] = byte;
125
- }
126
- return out;
127
- }
128
10
  function decodeKeyMaterial(key) {
129
11
  if (key instanceof Uint8Array)
130
12
  return key;
@@ -151,6 +33,9 @@ export function createStaticFieldEncryptionKeys(args) {
151
33
  if (!raw)
152
34
  throw new Error(`Missing encryption key for kid "${kid}"`);
153
35
  const decoded = decodeKeyMaterial(raw);
36
+ if (decoded.length !== 32) {
37
+ throw new Error(`Encryption key for kid "${kid}" must be 32 bytes (got ${decoded.length})`);
38
+ }
154
39
  cache.set(kid, decoded);
155
40
  return decoded;
156
41
  },
@@ -177,11 +62,16 @@ function decodeEnvelope(prefix, value) {
177
62
  const [kid, nonceB64, ctB64] = parts;
178
63
  if (!kid || !nonceB64 || !ctB64)
179
64
  return null;
180
- return {
181
- kid,
182
- nonce: base64UrlToBytes(nonceB64),
183
- ciphertext: base64UrlToBytes(ctB64),
184
- };
65
+ try {
66
+ return {
67
+ kid,
68
+ nonce: base64UrlToBytes(nonceB64),
69
+ ciphertext: base64UrlToBytes(ctB64),
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
185
75
  }
186
76
  async function getKeyOrThrow(keys, kid) {
187
77
  const key = await keys.getKey(kid);
@@ -255,6 +145,7 @@ async function decryptValue(args) {
255
145
  function buildRuleIndex(rules) {
256
146
  const byScopeTable = new Map();
257
147
  const tablesByScope = new Map();
148
+ const scopesByTable = new Map();
258
149
  for (const rule of rules) {
259
150
  const scope = rule.scope;
260
151
  const table = rule.table ?? '*';
@@ -274,9 +165,12 @@ function buildRuleIndex(rules) {
274
165
  byScopeTable.set(key, { fields: new Set(rule.fields), rowIdField });
275
166
  }
276
167
  if (table !== '*') {
277
- const set = tablesByScope.get(scope) ?? new Set();
278
- set.add(table);
279
- tablesByScope.set(scope, set);
168
+ const tables = tablesByScope.get(scope) ?? new Set();
169
+ tables.add(table);
170
+ tablesByScope.set(scope, tables);
171
+ const scopes = scopesByTable.get(table) ?? new Set();
172
+ scopes.add(scope);
173
+ scopesByTable.set(table, scopes);
280
174
  }
281
175
  }
282
176
  // Freeze sets to make accidental mutation harder.
@@ -286,7 +180,7 @@ function buildRuleIndex(rules) {
286
180
  rowIdField: v.rowIdField,
287
181
  });
288
182
  }
289
- return { byScopeTable, tablesByScope };
183
+ return { byScopeTable, tablesByScope, scopesByTable };
290
184
  }
291
185
  function getRuleConfig(index, args) {
292
186
  const exact = index.byScopeTable.get(`${args.scope}\u001f${args.table}`);
@@ -295,6 +189,26 @@ function getRuleConfig(index, args) {
295
189
  const wildcard = index.byScopeTable.get(`${args.scope}\u001f*`);
296
190
  return wildcard ?? null;
297
191
  }
192
+ function resolveScopeAndTable(args) {
193
+ const direct = getRuleConfig(args.index, {
194
+ scope: args.identifier,
195
+ table: args.identifier,
196
+ });
197
+ if (direct) {
198
+ return { scope: args.identifier, table: args.identifier };
199
+ }
200
+ const tablesForScope = args.index.tablesByScope.get(args.identifier);
201
+ if (tablesForScope && tablesForScope.size === 1) {
202
+ const table = Array.from(tablesForScope)[0];
203
+ return { scope: args.identifier, table };
204
+ }
205
+ const scopesForTable = args.index.scopesByTable.get(args.identifier);
206
+ if (scopesForTable && scopesForTable.size === 1) {
207
+ const scope = Array.from(scopesForTable)[0];
208
+ return { scope, table: args.identifier };
209
+ }
210
+ return { scope: args.identifier, table: args.identifier };
211
+ }
298
212
  function inferSnapshotTable(args) {
299
213
  if (isRecord(args.row)) {
300
214
  const tn = args.row.table_name;
@@ -442,7 +356,7 @@ function resolveRefreshTargets(args) {
442
356
  fields: Array.from(target.fields),
443
357
  }));
444
358
  }
445
- export async function refreshEncryptedFields(options) {
359
+ async function refreshEncryptedFields(options) {
446
360
  const prefix = options.envelopePrefix ?? DEFAULT_PREFIX;
447
361
  if (!prefix.endsWith(':')) {
448
362
  throw new Error('RefreshEncryptedFieldsOptions.envelopePrefix must end with ":"');
@@ -468,6 +382,7 @@ export async function refreshEncryptedFields(options) {
468
382
  let rowsScanned = 0;
469
383
  let rowsUpdated = 0;
470
384
  let fieldsUpdated = 0;
385
+ const updatedRows = [];
471
386
  await options.db.transaction().execute(async (trx) => {
472
387
  for (const target of targets) {
473
388
  const columns = [target.rowIdField, ...target.fields];
@@ -530,9 +445,21 @@ export async function refreshEncryptedFields(options) {
530
445
  `.execute(trx);
531
446
  rowsUpdated += 1;
532
447
  fieldsUpdated += changedFields;
448
+ updatedRows.push({ table: target.table, rowId });
533
449
  }
534
450
  }
535
451
  });
452
+ if (updatedRows.length > 0 && options.engine) {
453
+ const deduped = new Map();
454
+ for (const row of updatedRows) {
455
+ deduped.set(`${row.table}\u001f${row.rowId}`, row);
456
+ }
457
+ options.engine.recordLocalMutations(Array.from(deduped.values()).map((row) => ({
458
+ table: row.table,
459
+ rowId: row.rowId,
460
+ op: 'upsert',
461
+ })));
462
+ }
536
463
  return {
537
464
  tablesProcessed: targets.length,
538
465
  rowsScanned,
@@ -552,6 +479,7 @@ export function createFieldEncryptionPlugin(pluginOptions) {
552
479
  name,
553
480
  refreshEncryptedFields: (options) => refreshEncryptedFields({
554
481
  db: options.db,
482
+ engine: options.engine,
555
483
  rules: pluginOptions.rules,
556
484
  keys: pluginOptions.keys,
557
485
  envelopePrefix: prefix,
@@ -570,6 +498,10 @@ export function createFieldEncryptionPlugin(pluginOptions) {
570
498
  if (!op.payload)
571
499
  return op;
572
500
  const payload = op.payload;
501
+ const target = resolveScopeAndTable({
502
+ index,
503
+ identifier: op.table,
504
+ });
573
505
  const nextPayload = await transformRecordFields({
574
506
  ctx,
575
507
  index,
@@ -577,8 +509,8 @@ export function createFieldEncryptionPlugin(pluginOptions) {
577
509
  prefix,
578
510
  decryptionErrorMode,
579
511
  mode: 'encrypt',
580
- scope: op.table,
581
- table: op.table,
512
+ scope: target.scope,
513
+ table: target.table,
582
514
  rowId: op.row_id,
583
515
  record: payload,
584
516
  });
@@ -604,6 +536,10 @@ export function createFieldEncryptionPlugin(pluginOptions) {
604
536
  return r;
605
537
  if (!isRecord(r.server_row))
606
538
  return r;
539
+ const target = resolveScopeAndTable({
540
+ index,
541
+ identifier: op.table,
542
+ });
607
543
  const nextRow = await transformRecordFields({
608
544
  ctx,
609
545
  index,
@@ -611,8 +547,8 @@ export function createFieldEncryptionPlugin(pluginOptions) {
611
547
  prefix,
612
548
  decryptionErrorMode,
613
549
  mode: 'decrypt',
614
- scope: op.table,
615
- table: op.table,
550
+ scope: target.scope,
551
+ table: target.table,
616
552
  rowId: op.row_id,
617
553
  record: r.server_row,
618
554
  });
@@ -672,6 +608,10 @@ export function createFieldEncryptionPlugin(pluginOptions) {
672
608
  return change;
673
609
  if (!isRecord(change.row_json))
674
610
  return change;
611
+ const target = resolveScopeAndTable({
612
+ index,
613
+ identifier: change.table,
614
+ });
675
615
  const nextRow = await transformRecordFields({
676
616
  ctx,
677
617
  index,
@@ -679,8 +619,8 @@ export function createFieldEncryptionPlugin(pluginOptions) {
679
619
  prefix,
680
620
  decryptionErrorMode,
681
621
  mode: 'decrypt',
682
- scope: change.table,
683
- table: change.table,
622
+ scope: target.scope,
623
+ table: target.table,
684
624
  rowId: change.row_id,
685
625
  record: change.row_json,
686
626
  });