@spooky-sync/core 0.0.1-canary.305 → 0.0.1-canary.307
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/dist/index.js +31 -16
- package/dist/worker-edges.js +1 -1
- package/package.json +4 -4
- package/src/boot/auth-flip.saga.test.ts +57 -4
- package/src/boot/auth-flip.saga.ts +24 -9
- package/src/mutation/rows.ts +2 -2
- package/src/query/fetch.saga.ts +1 -1
- package/src/utils/index.ts +3 -3
- package/src/utils/utils.test.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -555,11 +555,11 @@ function classifySyncError(error) {
|
|
|
555
555
|
//#endregion
|
|
556
556
|
//#region src/utils/index.ts
|
|
557
557
|
const encodeRecordId = (recordId) => {
|
|
558
|
-
return `${recordId.table.
|
|
558
|
+
return `${recordId.table.name}:${recordId.id}`;
|
|
559
559
|
};
|
|
560
560
|
const extractTablePart = (id) => {
|
|
561
561
|
if (typeof id === "string") return id.split(":")[0];
|
|
562
|
-
return id.table.
|
|
562
|
+
return id.table.name;
|
|
563
563
|
};
|
|
564
564
|
const parseRecordIdString = (id) => {
|
|
565
565
|
const [table, ...idParts] = id.split(":");
|
|
@@ -1308,9 +1308,9 @@ function parseStoredRecordId(id) {
|
|
|
1308
1308
|
}
|
|
1309
1309
|
/** Stable `table:id` string for an id read from the store, escaping removed. */
|
|
1310
1310
|
const storedIdString = (id) => {
|
|
1311
|
-
if (id instanceof RecordId$1) return `${id.table}:${String(id.id)}`;
|
|
1311
|
+
if (id instanceof RecordId$1) return `${id.table.name}:${String(id.id)}`;
|
|
1312
1312
|
const rid = parseStoredRecordId(String(id));
|
|
1313
|
-
return `${rid.table}:${rid.id}`;
|
|
1313
|
+
return `${rid.table.name}:${rid.id}`;
|
|
1314
1314
|
};
|
|
1315
1315
|
/** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
|
|
1316
1316
|
function createdAtFromId(id) {
|
|
@@ -2178,8 +2178,8 @@ function trimHistory(history, cap) {
|
|
|
2178
2178
|
|
|
2179
2179
|
//#endregion
|
|
2180
2180
|
//#region src/modules/devtools/index.ts
|
|
2181
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2182
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2181
|
+
const CORE_VERSION = "0.0.1-canary.307";
|
|
2182
|
+
const WASM_VERSION = "0.0.1-canary.307";
|
|
2183
2183
|
const SURREAL_VERSION = "3.0.3";
|
|
2184
2184
|
var DevToolsService = class DevToolsService {
|
|
2185
2185
|
version = CORE_VERSION;
|
|
@@ -6087,7 +6087,7 @@ function* landChunk(env, requested, rows, versions, state, epoch) {
|
|
|
6087
6087
|
const rid = row?.id;
|
|
6088
6088
|
if (!rid || typeof rid !== "object") continue;
|
|
6089
6089
|
const id = encodeRecordId(rid);
|
|
6090
|
-
const table =
|
|
6090
|
+
const table = rid.table.name;
|
|
6091
6091
|
const version = versions.get(id) ?? 0;
|
|
6092
6092
|
const columns = env.schema.tables.find((t) => t.name === table)?.columns;
|
|
6093
6093
|
const cleaned = columns ? cleanRecord(columns, row) : row;
|
|
@@ -6935,19 +6935,25 @@ function* rebindQueries() {
|
|
|
6935
6935
|
//#region src/boot/auth-flip.saga.ts
|
|
6936
6936
|
/**
|
|
6937
6937
|
* The signed-in principal changed (sign-in, sign-out, boot verification).
|
|
6938
|
-
* Identity first (routing and `$auth` for the local SSP), then the
|
|
6939
|
-
*
|
|
6938
|
+
* Identity first (routing and `$auth` for the local SSP), then the salt if
|
|
6939
|
+
* the principal really changed, then the bucket, then the verified user row.
|
|
6940
|
+
*
|
|
6941
|
+
* The salt goes before the bucket because the switch re-keys every active
|
|
6942
|
+
* query (`rebindQueries`) from the salt it finds. A query's remote id hashes
|
|
6943
|
+
* the salt but not the principal, so re-keyed under the old salt it named the
|
|
6944
|
+
* view the previous principal still held, and the SSP refused it as another
|
|
6945
|
+
* identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
|
|
6946
|
+
* every impersonation start, stop and token lapse refused a tab's queries for
|
|
6947
|
+
* the 10 minutes of that TTL, each refusal a failed sync round, so the rail
|
|
6948
|
+
* read "Can't reach the server" the whole time.
|
|
6940
6949
|
*/
|
|
6941
6950
|
function* authFlip(env, userId) {
|
|
6942
6951
|
const authId = yield fx.service("auth.sessionAuthId");
|
|
6943
6952
|
const access = yield fx.service("auth.access");
|
|
6944
6953
|
yield fx.state.update(setIdentity({ userId }));
|
|
6945
6954
|
yield fx.service("ssp.setSessionAuth", authId, access);
|
|
6946
|
-
const
|
|
6947
|
-
|
|
6948
|
-
yield fx.state.update(setIdentity({ pendingBucket: target }));
|
|
6949
|
-
yield* bucketSwitch(env, target, (yield fx.service("local.currentBucketId")) === target ? null : yield fx.service("local.beginSwitch"));
|
|
6950
|
-
if (authId !== (yield fx.state.read((s) => s.saltUserId))) {
|
|
6955
|
+
const resalted = authId !== (yield fx.state.read((s) => s.saltUserId));
|
|
6956
|
+
if (resalted) {
|
|
6951
6957
|
const salt = yield fx.id("salt");
|
|
6952
6958
|
yield fx.state.update(setIdentity({
|
|
6953
6959
|
sessionId: salt,
|
|
@@ -6955,6 +6961,15 @@ function* authFlip(env, userId) {
|
|
|
6955
6961
|
}));
|
|
6956
6962
|
yield fx.service("crdt.setSessionId", salt);
|
|
6957
6963
|
}
|
|
6964
|
+
const target = bucketIdForUser(userId);
|
|
6965
|
+
yield fx.service("hint.write", target);
|
|
6966
|
+
yield fx.state.update(setIdentity({ pendingBucket: target }));
|
|
6967
|
+
const current = yield fx.service("local.currentBucketId");
|
|
6968
|
+
yield* bucketSwitch(env, target, current === target ? null : yield fx.service("local.beginSwitch"));
|
|
6969
|
+
if (resalted && current === target) {
|
|
6970
|
+
yield* rebindQueries();
|
|
6971
|
+
yield fx.dispatch({ type: "EnsureRegistered" });
|
|
6972
|
+
}
|
|
6958
6973
|
yield* persistVerifiedUser(env);
|
|
6959
6974
|
}
|
|
6960
6975
|
/**
|
|
@@ -6966,7 +6981,7 @@ function* persistVerifiedUser(env) {
|
|
|
6966
6981
|
const row = yield fx.service("auth.currentUser");
|
|
6967
6982
|
if (!row || !(row.id instanceof RecordId$1) || Object.keys(row).length <= 1) return;
|
|
6968
6983
|
const rid = row.id;
|
|
6969
|
-
const table =
|
|
6984
|
+
const table = rid.table.name;
|
|
6970
6985
|
const columns = columnsFor(env, table);
|
|
6971
6986
|
if (!columns) return;
|
|
6972
6987
|
const id = encodeRecordId(rid);
|
|
@@ -14588,7 +14603,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14588
14603
|
return new TabsCoordinator({
|
|
14589
14604
|
tabId: s.tabId,
|
|
14590
14605
|
fingerprint: computeTabsFingerprint({
|
|
14591
|
-
coreVersion: "0.0.1-canary.
|
|
14606
|
+
coreVersion: "0.0.1-canary.307",
|
|
14592
14607
|
schemaHash: hash53(config.schemaSurql),
|
|
14593
14608
|
endpoint: config.database.endpoint ?? "",
|
|
14594
14609
|
namespace: config.database.namespace,
|
package/dist/worker-edges.js
CHANGED
|
@@ -265,7 +265,7 @@ function parseValue(name, column, value) {
|
|
|
265
265
|
//#endregion
|
|
266
266
|
//#region src/utils/index.ts
|
|
267
267
|
const encodeRecordId = (recordId) => {
|
|
268
|
-
return `${recordId.table.
|
|
268
|
+
return `${recordId.table.name}:${recordId.id}`;
|
|
269
269
|
};
|
|
270
270
|
const parseRecordIdString = (id) => {
|
|
271
271
|
const [table, ...idParts] = id.split(":");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.307",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,15 +75,15 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.307",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.307",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
|
83
83
|
"fast-json-patch": "^3.1.1",
|
|
84
84
|
"loro-crdt": "^1.5.6",
|
|
85
85
|
"pino": "^10.1.0",
|
|
86
|
-
"surrealdb": "2.0
|
|
86
|
+
"surrealdb": "2.1.0"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
89
|
"@types/node": "^22.5.2",
|
|
@@ -6,11 +6,13 @@ import { buildEntry, buildState } from '../testing/build';
|
|
|
6
6
|
import * as R from '../state/reducers';
|
|
7
7
|
import { defaultEnv } from '../query/env';
|
|
8
8
|
import { authFlip, persistVerifiedUser } from './auth-flip.saga';
|
|
9
|
+
import { queryHashInput } from '../query/hash';
|
|
10
|
+
import { sha256Hex } from '../utils/sha256';
|
|
9
11
|
|
|
10
12
|
const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
|
|
11
13
|
|
|
12
14
|
describe('authFlip', () => {
|
|
13
|
-
it('sets identity and $auth before the bucket work, writes the hint, switches,
|
|
15
|
+
it('sets identity and $auth, rotates the salt for a new principal before the bucket work, writes the hint, switches, persists the user row', async () => {
|
|
14
16
|
const released: string[] = [];
|
|
15
17
|
const svc = fakeServices({
|
|
16
18
|
'auth.sessionAuthId': () => 'user:abc',
|
|
@@ -27,14 +29,65 @@ describe('authFlip', () => {
|
|
|
27
29
|
handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
28
30
|
});
|
|
29
31
|
const names = svc.names();
|
|
30
|
-
expect(names.slice(0,
|
|
31
|
-
expect(svc.calls[3]).toEqual(['
|
|
32
|
+
expect(names.slice(0, 6)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'crdt.setSessionId', 'hint.write', 'local.currentBucketId']);
|
|
33
|
+
expect(svc.calls[3]).toEqual(['crdt.setSessionId', ['salt-1']]);
|
|
34
|
+
expect(svc.calls[4]).toEqual(['hint.write', ['abc']]);
|
|
32
35
|
expect(names).toContain('local.switchStore');
|
|
33
|
-
expect(names).toContain('crdt.setSessionId');
|
|
34
36
|
expect(released).toEqual(['gate']);
|
|
35
37
|
expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
|
|
36
38
|
expect(out.state.versions.get('user:abc')).toBe(1);
|
|
37
39
|
});
|
|
40
|
+
it("re-keys active queries under the new principal's salt, so no remote id names the previous principal's view", async () => {
|
|
41
|
+
const svc = fakeServices({
|
|
42
|
+
'auth.sessionAuthId': () => 'user:target',
|
|
43
|
+
'auth.access': () => 'account',
|
|
44
|
+
'local.currentBucketId': () => 'admin',
|
|
45
|
+
'local.beginSwitch': () => () => undefined,
|
|
46
|
+
'local.usesSurqlSchema': () => false,
|
|
47
|
+
'auth.token': () => 'jwt',
|
|
48
|
+
'auth.currentUser': () => null,
|
|
49
|
+
});
|
|
50
|
+
const def = { surql: 'SELECT * FROM notification', params: {} };
|
|
51
|
+
const before = await sha256Hex(queryHashInput(def, 'admin-salt'));
|
|
52
|
+
const state = R.putQuery(
|
|
53
|
+
buildEntry({ def: { hash: 'q', ...def, id: new RecordId('_00_query', before) }, lifecycle: { phase: 'live', remote: 'registered' } })
|
|
54
|
+
)({ ...buildState(), sessionId: 'admin-salt', saltUserId: 'user:admin', bucketId: 'admin' });
|
|
55
|
+
const out = await runPure(authFlip(env, 'user:target'), {
|
|
56
|
+
state,
|
|
57
|
+
handlers: {
|
|
58
|
+
service: svc.handler,
|
|
59
|
+
'local.getById': () => null,
|
|
60
|
+
'ssp.register': () => ({ localArray: [], timings: {} }),
|
|
61
|
+
'local.query': () => [],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const id = String(out.state.queries.get('q')!.def.id.id);
|
|
65
|
+
expect(out.state.sessionId).toBe('salt-1');
|
|
66
|
+
expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
|
|
67
|
+
expect(id).not.toBe(before);
|
|
68
|
+
});
|
|
69
|
+
it('a new principal on the same bucket still re-keys its queries', async () => {
|
|
70
|
+
const svc = fakeServices({
|
|
71
|
+
'auth.sessionAuthId': () => 'user:b',
|
|
72
|
+
'auth.access': () => 'account',
|
|
73
|
+
'local.currentBucketId': () => 'anon',
|
|
74
|
+
'auth.currentUser': () => null,
|
|
75
|
+
});
|
|
76
|
+
const def = { surql: 'SELECT * FROM thing', params: {} };
|
|
77
|
+
const state = R.putQuery(buildEntry({ def: { hash: 'q', ...def }, lifecycle: { phase: 'live', remote: 'registered' } }))({
|
|
78
|
+
...buildState(),
|
|
79
|
+
sessionId: 'old',
|
|
80
|
+
saltUserId: 'user:a',
|
|
81
|
+
bucketId: 'anon',
|
|
82
|
+
});
|
|
83
|
+
const out = await runPure(authFlip(env, null), {
|
|
84
|
+
state,
|
|
85
|
+
handlers: { service: svc.handler, 'local.getById': () => null, 'ssp.register': () => ({ localArray: [], timings: {} }) },
|
|
86
|
+
});
|
|
87
|
+
expect(svc.names()).not.toContain('local.switchStore');
|
|
88
|
+
expect(String(out.state.queries.get('q')!.def.id.id)).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
|
|
89
|
+
expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
|
|
90
|
+
});
|
|
38
91
|
it('same principal on the same bucket: no switch, no salt rotation', async () => {
|
|
39
92
|
const svc = fakeServices({
|
|
40
93
|
'auth.sessionAuthId': () => 'user:abc',
|
|
@@ -8,29 +8,44 @@ import { columnsFor } from '../query/env';
|
|
|
8
8
|
import * as sql from '../query/sql';
|
|
9
9
|
import { encodeRecordId } from '../utils/index';
|
|
10
10
|
import { cleanRecord } from '../utils/parser';
|
|
11
|
-
import { bucketSwitch } from './bucket-switch.saga';
|
|
11
|
+
import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The signed-in principal changed (sign-in, sign-out, boot verification).
|
|
15
|
-
* Identity first (routing and `$auth` for the local SSP), then the
|
|
16
|
-
*
|
|
15
|
+
* Identity first (routing and `$auth` for the local SSP), then the salt if
|
|
16
|
+
* the principal really changed, then the bucket, then the verified user row.
|
|
17
|
+
*
|
|
18
|
+
* The salt goes before the bucket because the switch re-keys every active
|
|
19
|
+
* query (`rebindQueries`) from the salt it finds. A query's remote id hashes
|
|
20
|
+
* the salt but not the principal, so re-keyed under the old salt it named the
|
|
21
|
+
* view the previous principal still held, and the SSP refused it as another
|
|
22
|
+
* identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
|
|
23
|
+
* every impersonation start, stop and token lapse refused a tab's queries for
|
|
24
|
+
* the 10 minutes of that TTL, each refusal a failed sync round, so the rail
|
|
25
|
+
* read "Can't reach the server" the whole time.
|
|
17
26
|
*/
|
|
18
27
|
export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
|
|
19
28
|
const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
|
|
20
29
|
const access = (yield fx.service('auth.access')) as string | null;
|
|
21
30
|
yield fx.state.update(R.setIdentity({ userId }));
|
|
22
31
|
yield fx.service('ssp.setSessionAuth', authId, access);
|
|
32
|
+
const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
|
|
33
|
+
const resalted = authId !== saltUserId;
|
|
34
|
+
if (resalted) {
|
|
35
|
+
const salt = (yield fx.id('salt')) as string;
|
|
36
|
+
yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
|
|
37
|
+
yield fx.service('crdt.setSessionId', salt);
|
|
38
|
+
}
|
|
23
39
|
const target = bucketIdForUser(userId);
|
|
24
40
|
yield fx.service('hint.write', target);
|
|
25
41
|
yield fx.state.update(R.setIdentity({ pendingBucket: target }));
|
|
26
42
|
const current = (yield fx.service('local.currentBucketId')) as string;
|
|
27
43
|
const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
|
|
28
44
|
yield* bucketSwitch(env, target, release);
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
yield fx.
|
|
33
|
-
yield fx.service('crdt.setSessionId', salt);
|
|
45
|
+
// No switch means nothing re-keyed the queries under the new salt.
|
|
46
|
+
if (resalted && current === target) {
|
|
47
|
+
yield* rebindQueries();
|
|
48
|
+
yield fx.dispatch({ type: 'EnsureRegistered' });
|
|
34
49
|
}
|
|
35
50
|
yield* persistVerifiedUser(env);
|
|
36
51
|
}
|
|
@@ -44,7 +59,7 @@ export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
|
|
|
44
59
|
const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
|
|
45
60
|
if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
|
|
46
61
|
const rid = row.id as RecordId<string>;
|
|
47
|
-
const table =
|
|
62
|
+
const table = rid.table.name;
|
|
48
63
|
const columns = columnsFor(env, table);
|
|
49
64
|
if (!columns) return;
|
|
50
65
|
const id = encodeRecordId(rid);
|
package/src/mutation/rows.ts
CHANGED
|
@@ -53,9 +53,9 @@ export function parseStoredRecordId(id: string): RecordId<string> {
|
|
|
53
53
|
|
|
54
54
|
/** Stable `table:id` string for an id read from the store, escaping removed. */
|
|
55
55
|
export const storedIdString = (id: unknown): string => {
|
|
56
|
-
if (id instanceof RecordId) return `${id.table}:${String(id.id)}`;
|
|
56
|
+
if (id instanceof RecordId) return `${id.table.name}:${String(id.id)}`;
|
|
57
57
|
const rid = parseStoredRecordId(String(id));
|
|
58
|
-
return `${rid.table}:${rid.id}`;
|
|
58
|
+
return `${rid.table.name}:${rid.id}`;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
/** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
|
package/src/query/fetch.saga.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function* landChunk(
|
|
|
82
82
|
const rid = row?.id as RecordId<string> | undefined;
|
|
83
83
|
if (!rid || typeof rid !== 'object') continue;
|
|
84
84
|
const id = encodeRecordId(rid);
|
|
85
|
-
const table =
|
|
85
|
+
const table = rid.table.name;
|
|
86
86
|
const version = versions.get(id) ?? 0;
|
|
87
87
|
const columns = env.schema.tables.find((t) => t.name === table)?.columns;
|
|
88
88
|
const cleaned = columns ? cleanRecord(columns, row) : row;
|
package/src/utils/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const compareRecordIds = (
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export const encodeRecordId = (recordId: RecordId<string>): string => {
|
|
22
|
-
return `${recordId.table.
|
|
22
|
+
return `${recordId.table.name}:${recordId.id}`;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
export const extractIdPart = (id: string | RecordId<string>): string => {
|
|
@@ -39,7 +39,7 @@ export const extractTablePart = (id: string | RecordId<string>): string => {
|
|
|
39
39
|
if (typeof id === 'string') {
|
|
40
40
|
return id.split(':')[0];
|
|
41
41
|
}
|
|
42
|
-
return id.table.
|
|
42
|
+
return id.table.name;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
export const parseRecordIdString = (id: string): RecordId<string> => {
|
|
@@ -76,7 +76,7 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
|
|
|
76
76
|
const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
|
|
77
77
|
if ((column.recordId || relation) && encoded[field] !== null && encoded[field] !== undefined) {
|
|
78
78
|
if (encoded[field] instanceof RecordId) {
|
|
79
|
-
encoded[field] = `${encoded[field].table.
|
|
79
|
+
encoded[field] = `${encoded[field].table.name}:${encoded[field].id}`;
|
|
80
80
|
} else if (
|
|
81
81
|
relation &&
|
|
82
82
|
(encoded[field] instanceof Object || Array.isArray(encoded[field]))
|
package/src/utils/utils.test.ts
CHANGED
|
@@ -105,7 +105,7 @@ describe('parseRecordIdString', () => {
|
|
|
105
105
|
|
|
106
106
|
it('parses id containing colons', () => {
|
|
107
107
|
const rid = parseRecordIdString('table:part1:part2');
|
|
108
|
-
expect(rid.table.
|
|
108
|
+
expect(rid.table.name).toBe('table');
|
|
109
109
|
expect(rid.id).toBe('part1:part2');
|
|
110
110
|
});
|
|
111
111
|
});
|