Skip to content

Commit 9431d3c

Browse files
committed
refactor(bridgeAccess): use accesses.update for in-place updates (Plan 58 Phase 4b)
Plan 66's accesses.update lets us preserve the access id (token, apiEndpoint) across permission changes. The old delete+create pattern is replaced with a single in-place update call. Changes: - `ensureBridgeAccess` updateIfDifferent branch: `connection.updateAccess(id, {permissions, clientData})` instead of delete+create. apiEndpoint preserved; id becomes composite `<base>:<serial>`. - `BridgeAccessResult.recreated` (boolean) dropped — no recreate happens. Replaced with `updated: boolean` (true when accesses.update was called). - 1-retry `StaleAccessIdError` budget: if another writer updates the access between our `accesses.get` and our `accesses.update`, we refetch and retry once. - `recreateBridgeAccess` (public) + `_recreateFromExisting` (private) dropped entirely — no workspace callers, dead code from the Plan 27 era. clientData merge: empirically verified on demo (Plan 66 deploy) that `accesses.update` MERGES clientData (existing keys preserved when not in payload), so previousAccessIds chains on legacy bridge accesses survive automatically without explicit read-merge-write.
1 parent 0922492 commit 9431d3c

3 files changed

Lines changed: 69 additions & 125 deletions

File tree

tests/bridgeAccess.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ import { assert } from './test-utils/deps-node.js';
55
// The main integration test is in the bridge-mira tests.
66

77
// Import to verify the module loads correctly
8-
import { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from '../ts/appTemplates/bridgeAccess.ts';
8+
import { getOrCreateBridgeAccess, ensureBridgeAccess } from '../ts/appTemplates/bridgeAccess.ts';
99

1010
describe('[BACC] Bridge Access helpers', function () {
11-
it('[BA01] should export all three helper functions', () => {
11+
it('[BA01] should export both helper functions', () => {
1212
assert.equal(typeof getOrCreateBridgeAccess, 'function');
13-
assert.equal(typeof recreateBridgeAccess, 'function');
1413
assert.equal(typeof ensureBridgeAccess, 'function');
1514
});
1615

1716
it('[BA02] should be importable from appTemplates', async () => {
1817
const appTemplates = await import('../ts/appTemplates/appTemplates.ts');
1918
assert.equal(typeof appTemplates.getOrCreateBridgeAccess, 'function');
20-
assert.equal(typeof appTemplates.recreateBridgeAccess, 'function');
2119
assert.equal(typeof appTemplates.ensureBridgeAccess, 'function');
20+
// recreateBridgeAccess was dropped in Plan 58 Phase 4b — accesses.update replaces delete+create.
21+
assert.equal(typeof appTemplates.recreateBridgeAccess, 'undefined');
2222
});
2323
});

ts/appTemplates/appTemplates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CollectorRequest } from './CollectorRequest.ts';
88
import { Contact } from './Contact.ts';
99
export type { ContactInvite } from './Contact.ts';
1010
export type { AccessUpdateRequest, AccessUpdateRequestContent, AccessUpdateAction } from './interfaces.ts';
11-
export { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from './bridgeAccess.ts';
11+
export { getOrCreateBridgeAccess, ensureBridgeAccess } from './bridgeAccess.ts';
1212
export type { BridgeAccessOptions, BridgeAccessResult } from './bridgeAccess.ts';
1313
export {
1414
getSectionItemLabels,

ts/appTemplates/bridgeAccess.ts

Lines changed: 64 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ export interface BridgeAccessOptions {
1414
export interface BridgeAccessResult {
1515
apiEndpoint: string;
1616
accessId: string;
17-
/** Whether the access was newly created (vs. reused) */
17+
/** Whether the access was newly created (vs. reused/updated) */
1818
created: boolean;
19-
/** Whether the access was recreated (old deleted, new created) */
20-
recreated: boolean;
19+
/** Whether an existing access was updated in place via accesses.update */
20+
updated: boolean;
2121
}
2222

2323
/**
@@ -39,7 +39,7 @@ export async function getOrCreateBridgeAccess (
3939
apiEndpoint: existing.apiEndpoint,
4040
accessId: existing.id,
4141
created: false,
42-
recreated: false
42+
updated: false
4343
};
4444
}
4545

@@ -53,146 +53,90 @@ export async function getOrCreateBridgeAccess (
5353
apiEndpoint: access.apiEndpoint,
5454
accessId: access.id,
5555
created: true,
56-
recreated: false
57-
};
58-
}
59-
60-
/**
61-
* Recreate a bridge access with updated permissions/clientData.
62-
* Deletes the old access and creates a new one, carrying forward previousAccessIds
63-
* so that events created under old accesses are still attributable.
64-
*
65-
* @param connection - Pryv connection to the user's account (personal token)
66-
* @param options - new access configuration
67-
*/
68-
export async function recreateBridgeAccess (
69-
connection: pryv.Connection,
70-
options: BridgeAccessOptions
71-
): Promise<BridgeAccessResult> {
72-
const accesses = await (connection as any).apiOne('accesses.get', {}, 'accesses');
73-
const existing = accesses.find((a: any) => a.name === options.name);
74-
75-
// Build previousAccessIds chain
76-
const previousAccessIds: string[] = [];
77-
if (existing) {
78-
if (existing.id) previousAccessIds.push(existing.id);
79-
const oldPrevIds = existing.clientData?.previousAccessIds;
80-
if (Array.isArray(oldPrevIds)) {
81-
for (const id of oldPrevIds) {
82-
if (!previousAccessIds.includes(id)) previousAccessIds.push(id);
83-
}
84-
}
85-
// Delete old access
86-
await (connection as any).apiOne('accesses.delete', { id: existing.id }, 'accessDeletion');
87-
logger.info('Deleted old bridge access for recreation', { name: options.name, oldId: existing.id });
88-
}
89-
90-
// Merge previousAccessIds into clientData
91-
const clientData = {
92-
...options.clientData,
93-
previousAccessIds: previousAccessIds.length > 0 ? previousAccessIds : undefined
94-
};
95-
96-
const access = await (connection as any).apiOne('accesses.create', {
97-
name: options.name,
98-
permissions: options.permissions,
99-
clientData
100-
}, 'access');
101-
102-
return {
103-
apiEndpoint: access.apiEndpoint,
104-
accessId: access.id,
105-
created: true,
106-
recreated: existing != null
56+
updated: false
10757
};
10858
}
10959

11060
/**
11161
* Get or create a bridge access, with optional permission update detection.
112-
* If the access exists but permissions differ, recreates it with the new permissions
113-
* while preserving previousAccessIds for event attribution.
62+
*
63+
* If the access exists and `updateIfDifferent` is set and permissions differ,
64+
* updates it in place via `accesses.update` (Plan 66). The access id becomes
65+
* composite (`<base>:<serial>`) but the token and apiEndpoint are preserved.
66+
*
67+
* Server-side `clientData` merge means any pre-existing keys on the access
68+
* (notably `previousAccessIds` from the legacy delete+create era) are
69+
* preserved automatically — we only send the keys we want to set.
70+
*
71+
* `StaleAccessIdError` handling: if another writer updates the access between
72+
* our `accesses.get` and our `accesses.update`, we refetch + retry once.
73+
* Two consecutive stale errors propagate.
11474
*
11575
* @param connection - Pryv connection to the user's account (personal token)
11676
* @param options - access configuration
117-
* @param options.updateIfDifferent - if true, recreate when permissions differ (default: false)
77+
* @param options.updateIfDifferent - if true, update permissions in place when they differ (default: false)
11878
*/
11979
export async function ensureBridgeAccess (
12080
connection: pryv.Connection,
12181
options: BridgeAccessOptions & { updateIfDifferent?: boolean }
12282
): Promise<BridgeAccessResult> {
123-
const accesses = await (connection as any).apiOne('accesses.get', {}, 'accesses');
124-
const existing = accesses.find((a: any) => a.name === options.name);
125-
126-
if (existing) {
127-
// Check if permissions match
128-
if (options.updateIfDifferent && !permissionsMatch(existing.permissions, options.permissions)) {
129-
logger.info('Bridge access permissions differ, recreating', { name: options.name });
130-
// Can't re-fetch — pass existing directly to avoid double API call
131-
return await _recreateFromExisting(connection, existing, options);
83+
let attempt = 0;
84+
while (true) {
85+
const accesses = await (connection as any).apiOne('accesses.get', {}, 'accesses');
86+
const existing = accesses.find((a: any) => a.name === options.name);
87+
88+
if (!existing) {
89+
const access = await (connection as any).apiOne('accesses.create', {
90+
name: options.name,
91+
permissions: options.permissions,
92+
clientData: options.clientData || {}
93+
}, 'access');
94+
return {
95+
apiEndpoint: access.apiEndpoint,
96+
accessId: access.id,
97+
created: true,
98+
updated: false
99+
};
132100
}
133-
return {
134-
apiEndpoint: existing.apiEndpoint,
135-
accessId: existing.id,
136-
created: false,
137-
recreated: false
138-
};
139-
}
140-
141-
const access = await (connection as any).apiOne('accesses.create', {
142-
name: options.name,
143-
permissions: options.permissions,
144-
clientData: options.clientData || {}
145-
}, 'access');
146101

147-
return {
148-
apiEndpoint: access.apiEndpoint,
149-
accessId: access.id,
150-
created: true,
151-
recreated: false
152-
};
153-
}
102+
if (!options.updateIfDifferent || permissionsMatch(existing.permissions, options.permissions)) {
103+
return {
104+
apiEndpoint: existing.apiEndpoint,
105+
accessId: existing.id,
106+
created: false,
107+
updated: false
108+
};
109+
}
154110

155-
/** @private recreate from an already-fetched existing access */
156-
async function _recreateFromExisting (
157-
connection: pryv.Connection,
158-
existing: any,
159-
options: BridgeAccessOptions
160-
): Promise<BridgeAccessResult> {
161-
const previousAccessIds: string[] = [];
162-
if (existing.id) previousAccessIds.push(existing.id);
163-
const oldPrevIds = existing.clientData?.previousAccessIds;
164-
if (Array.isArray(oldPrevIds)) {
165-
for (const id of oldPrevIds) {
166-
if (!previousAccessIds.includes(id)) previousAccessIds.push(id);
111+
// Update in place. Server merges clientData; we only send our new keys.
112+
const updatePayload: Record<string, any> = { permissions: options.permissions };
113+
if (options.clientData != null) updatePayload.clientData = options.clientData;
114+
115+
try {
116+
logger.info('Bridge access permissions differ, updating', { name: options.name, id: existing.id });
117+
const updated = await (connection as any).updateAccess(existing.id, updatePayload);
118+
return {
119+
apiEndpoint: updated.apiEndpoint,
120+
accessId: updated.id,
121+
created: false,
122+
updated: true
123+
};
124+
} catch (e: any) {
125+
if (e instanceof (pryv as any).StaleAccessIdError && attempt === 0) {
126+
attempt++;
127+
logger.info('Bridge access stale on update, refetching and retrying once', { name: options.name });
128+
continue;
129+
}
130+
throw e;
167131
}
168132
}
169-
170-
await (connection as any).apiOne('accesses.delete', { id: existing.id }, 'accessDeletion');
171-
172-
const clientData = {
173-
...options.clientData,
174-
previousAccessIds: previousAccessIds.length > 0 ? previousAccessIds : undefined
175-
};
176-
177-
const access = await (connection as any).apiOne('accesses.create', {
178-
name: options.name,
179-
permissions: options.permissions,
180-
clientData
181-
}, 'access');
182-
183-
return {
184-
apiEndpoint: access.apiEndpoint,
185-
accessId: access.id,
186-
created: true,
187-
recreated: true
188-
};
189133
}
190134

191135
/** Compare two permission arrays (order-independent) */
192136
function permissionsMatch (a: any[], b: Permission[]): boolean {
193137
if (!a || !b) return false;
194138
if (a.length !== b.length) return false;
195-
const normalize = (p: any) => `${p.streamId || ''}:${p.level || ''}:${p.feature || ''}:${p.setting || ''}`;
139+
const normalize = (p: any): string => `${p.streamId || ''}:${p.level || ''}:${p.feature || ''}:${p.setting || ''}`;
196140
const setA = new Set(a.map(normalize));
197141
const setB = new Set(b.map(normalize));
198142
if (setA.size !== setB.size) return false;

0 commit comments

Comments
 (0)