diff --git a/src/backend/drivers/notification/NotificationDriver.test.ts b/src/backend/drivers/notification/NotificationDriver.test.ts
index 186c4b414..e27d8e23b 100644
--- a/src/backend/drivers/notification/NotificationDriver.test.ts
+++ b/src/backend/drivers/notification/NotificationDriver.test.ts
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
@@ -133,6 +133,34 @@ describe('NotificationDriver.create', () => {
expect(row?.type).toBe(NOTIF_TYPE);
});
+ it('returns the created notification before the replica has it', async () => {
+ const { actor } = await makeUser();
+ // sqlite's pread delegates to read, so the primary is pinned to the
+ // real one while replica reads of `notification` come back empty.
+ const db = server.clients.db;
+ const realRead = db.read.bind(db);
+ const pread = vi.spyOn(db, 'pread').mockImplementation(realRead);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (q, p) =>
+ /FROM `notification`/u.test(q) ? [] : realRead(q, p),
+ );
+ let result: Record | null;
+ try {
+ result = (await withActor(actor, () =>
+ driver.create({
+ object: { value: { type: NOTIF_TYPE, title: 'lagged' } },
+ }),
+ )) as Record | null;
+ } finally {
+ read.mockRestore();
+ pread.mockRestore();
+ }
+
+ expect(result?.uid).toEqual(expect.any(String));
+ expect(result?.value).toEqual({ type: NOTIF_TYPE, title: 'lagged' });
+ });
+
it('rejects a `value` with no `type` with 400', async () => {
const { actor } = await makeUser();
await expect(
diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts
index aa57155d3..0ff5253a3 100644
--- a/src/backend/drivers/notification/NotificationDriver.ts
+++ b/src/backend/drivers/notification/NotificationDriver.ts
@@ -182,6 +182,7 @@ export class NotificationDriver extends PuterDriver {
return this.#toClient(
await this.stores.notification.getByUid(uid, {
userId: actor.user.id,
+ primary: true,
}),
);
}
diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts
index f67c13bd6..2b09cce57 100644
--- a/src/backend/services/share/ShareService.test.ts
+++ b/src/backend/services/share/ShareService.test.ts
@@ -207,6 +207,37 @@ describe('ShareService', () => {
expect(listed.items.map((i) => i.entryUid)).toContain(file.uuid);
});
+ it('keeps the grant when the replica has not seen the new share row yet', async () => {
+ const owner = await makeUser();
+ const recipient = await makeUser();
+ const file = await makeFile(owner.user);
+
+ // sqlite's pread delegates to read, so the primary is pinned to the
+ // real one while replica reads of `share` come back empty.
+ const db = server.clients.db;
+ const realRead = db.read.bind(db);
+ const pread = vi.spyOn(db, 'pread').mockImplementation(realRead);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (q, p) =>
+ /FROM `share`/u.test(q) ? [] : realRead(q, p),
+ );
+ let result;
+ try {
+ result = await share(owner.actor, {
+ uid: file.uuid,
+ recipient: { email: recipient.email },
+ mode: 'read',
+ });
+ } finally {
+ read.mockRestore();
+ pread.mockRestore();
+ }
+
+ expect(result.mode).toBe('read');
+ expect(await canRead(recipient.actor, file.path)).toBe(true);
+ });
+
it('tells the account how much is shared with it, and an app only its page', async () => {
const owner = await makeUser();
const recipient = await makeUser();
diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js
index 3a5c28256..c5614c24f 100644
--- a/src/backend/stores/app/AppStore.js
+++ b/src/backend/stores/app/AppStore.js
@@ -420,7 +420,11 @@ export class AppStore extends PuterStore {
);
const affected = (result?.affectedRows ?? result?.changes ?? 0) > 0;
if (affected) {
- const fresh = await this.getById(appId);
+ // Not `getById`: on a cache miss it would cache the replica's
+ // pre-claim row.
+ const fresh = await this.#readFromDb('id', appId, {
+ primary: true,
+ });
if (fresh) {
await this.invalidate(fresh);
}
@@ -570,7 +574,7 @@ export class AppStore extends PuterStore {
await this.clearCacheTombstones(
this.#cacheKeysForApp({ id: insertId, uid, name: allowed.name }),
);
- const fresh = await this.getById(insertId);
+ const fresh = await this.#readBackById(insertId);
if (fresh?.name) {
await this.#clearOldAppNamesForName(fresh.name);
}
@@ -651,7 +655,7 @@ export class AppStore extends PuterStore {
await this.clearCacheTombstones(
this.#cacheKeysForApp({ id: insertId, uid, name: fields.name }),
);
- const fresh = await this.getById(insertId);
+ const fresh = await this.#readBackById(insertId);
await this.#invalidateListCachesForApps([fresh]);
return fresh;
}
@@ -685,7 +689,7 @@ export class AppStore extends PuterStore {
await this.#clearOldAppNamesForName(allowed.name);
}
- const fresh = await this.#readFromDb('id', appId);
+ const fresh = await this.#readFromDb('id', appId, { primary: true });
await this.#invalidateListCachesForApps([before, fresh]);
if (fresh) {
await this.#refreshCache({ ...fresh, ...allowed });
@@ -970,6 +974,18 @@ export class AppStore extends PuterStore {
return normalized;
}
+ /** A row this store just wrote, from the primary; cached on the way out. */
+ async #readBackById(id) {
+ const fresh = await this.#readFromDb('id', id, { primary: true });
+ if (!fresh) return null;
+ try {
+ await this.#writeCache(fresh);
+ } catch {
+ // Best effort; the next read fills it.
+ }
+ return fresh;
+ }
+
async #readFromDb(prop, value, { primary = false } = {}) {
const read = primary
? (sql, params) => this.clients.db.pread(sql, params)
@@ -1157,7 +1173,7 @@ export class AppStore extends PuterStore {
async #refreshCache(app) {
const keys = this.#cacheKeysForApp(app);
- // `update` reads the replica, so it can refresh a row already deleted.
+ // A delete can land between `update`'s read and this refresh.
await this.writeCacheUnlessDeleted(keys, () =>
this.#publishRefresh(keys, app),
);
diff --git a/src/backend/stores/app/AppStore.test.js b/src/backend/stores/app/AppStore.test.js
index 36efed1dd..158e3e70c 100644
--- a/src/backend/stores/app/AppStore.test.js
+++ b/src/backend/stores/app/AppStore.test.js
@@ -1129,6 +1129,92 @@ describe('AppStore CRUD and cache invalidation', () => {
const map = await appStore.getByUids(['app-nope']);
expect(map.size).toBe(0);
});
+
+ // -- behind a lagging replica --------------------------------------
+
+ /**
+ * Replica reads of `apps` see only `staleRows`. sqlite's pread delegates to
+ * read, so the primary is pinned to the real one.
+ */
+ const withLaggingReplica = async (staleRows, fn) => {
+ const realRead = db.read.bind(db);
+ const preadSpy = vi.spyOn(db, 'pread').mockImplementation(realRead);
+ const readSpy = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (sql, params) =>
+ /FROM `apps`/.test(sql)
+ ? staleRows.map((r) => ({ ...r }))
+ : realRead(sql, params),
+ );
+ try {
+ return await fn();
+ } finally {
+ readSpy.mockRestore();
+ preadSpy.mockRestore();
+ }
+ };
+
+ const rawRow = async (id) =>
+ (await db.read('SELECT * FROM `apps` WHERE `id` = ?', [id]))[0];
+
+ const cachedByUid = async (uid) =>
+ JSON.parse((await redis.get(`apps:uid:${uid}`)) ?? 'null');
+
+ it('returns and caches a created app before the replica has it', async () => {
+ const app = await withLaggingReplica([], () =>
+ createApp({ title: 'Lagged' }),
+ );
+ expect(app?.title).toBe('Lagged');
+ expect((await cachedByUid(app.uid))?.title).toBe('Lagged');
+
+ const origin = 'https://lagging-replica-bootstrap.test';
+ const uid = `app-${uuidv5(origin, uuidv5.URL)}`;
+ const bootstrapped = await withLaggingReplica([], () =>
+ appStore.createFromOrigin(uid, origin),
+ );
+ expect(bootstrapped?.uid).toBe(uid);
+ expect((await cachedByUid(uid))?.index_url).toBe(origin);
+ });
+
+ it('returns and caches the updated app, not a replica copy missing an earlier update', async () => {
+ const app = await createApp({ title: 'One' });
+ const stale = await rawRow(app.id);
+ await appStore.update(app.id, { title: 'Two' });
+
+ const updated = await withLaggingReplica([stale], () =>
+ appStore.update(app.id, { description: 'later' }),
+ );
+
+ expect(updated).toMatchObject({ title: 'Two', description: 'later' });
+ expect(await cachedByUid(app.uid)).toMatchObject({
+ title: 'Two',
+ description: 'later',
+ });
+ });
+
+ it('does not cache the pre-claim row a lagging replica still serves', async () => {
+ const name = freshName();
+ await db.write(
+ 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`) VALUES (?, ?, ?, ?)',
+ [`app-${name}`, name, name, `https://${name}.example.com/`],
+ );
+ // Straight from the table, so nothing is cached yet.
+ const [stale] = await db.read('SELECT * FROM `apps` WHERE `name` = ?', [
+ name,
+ ]);
+
+ await withLaggingReplica([stale], async () => {
+ expect(await appStore.claimOwnership(Number(stale.id), OWNER)).toBe(
+ true,
+ );
+ // Let any unawaited cache write land.
+ await new Promise((r) => setTimeout(r, 20));
+ });
+
+ // Nothing cached, or the claimed row; never the unowned copy.
+ const cached = await cachedByUid(stale.uid);
+ expect(cached === null || cached.owner_user_id === OWNER).toBe(true);
+ });
});
describe('AppStore detailed stats', () => {
diff --git a/src/backend/stores/notification/NotificationStore.js b/src/backend/stores/notification/NotificationStore.js
index 78b85d6a8..04e8c8cdb 100644
--- a/src/backend/stores/notification/NotificationStore.js
+++ b/src/backend/stores/notification/NotificationStore.js
@@ -23,16 +23,22 @@ import { PuterStore } from '../types';
export class NotificationStore extends PuterStore {
// -- Reads --------------------------------------------------------
- async getByUid(uid, { userId } = {}) {
+ /**
+ * Pass `primary` to read back a row just written; a replica may lag.
+ *
+ * @param {string} uid
+ * @param {{ userId?: number; primary?: boolean }} [opts]
+ */
+ async getByUid(uid, { userId, primary = false } = {}) {
const where =
userId !== undefined
? 'WHERE `uid` = ? AND `user_id` = ?'
: 'WHERE `uid` = ?';
const params = userId !== undefined ? [uid, userId] : [uid];
- const rows = await this.clients.db.read(
- `SELECT * FROM \`notification\` ${where} LIMIT 1`,
- params,
- );
+ const sql = `SELECT * FROM \`notification\` ${where} LIMIT 1`;
+ const rows = primary
+ ? await this.clients.db.pread(sql, params)
+ : await this.clients.db.read(sql, params);
return this.#normalizeRow(rows[0]) ?? null;
}
@@ -182,7 +188,7 @@ export class NotificationStore extends PuterStore {
'INSERT INTO `notification` (`uid`, `user_id`, `value`, `type`, `audience`, `app_uid`) VALUES (?, ?, ?, ?, ?, ?)',
[uid, userId, serialized, type, audience, appUid],
);
- return this.getByUid(uid, { userId });
+ return this.getByUid(uid, { userId, primary: true });
}
/**
diff --git a/src/backend/stores/notification/NotificationStore.test.js b/src/backend/stores/notification/NotificationStore.test.js
index a1e28e5b4..1a9a50642 100644
--- a/src/backend/stores/notification/NotificationStore.test.js
+++ b/src/backend/stores/notification/NotificationStore.test.js
@@ -18,7 +18,7 @@
*/
import { v4 as uuidv4 } from 'uuid';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { setupTestServer } from '../../testUtil.ts';
describe('NotificationStore', () => {
@@ -84,6 +84,32 @@ describe('NotificationStore', () => {
expect(created.value).toBe('plain text');
});
+ it('returns the created row before the replica has it', async () => {
+ // sqlite's pread delegates to read, so the primary is pinned to the
+ // real one while replica reads of `notification` come back empty.
+ const db = server.clients.db;
+ const realRead = db.read.bind(db);
+ const pread = vi.spyOn(db, 'pread').mockImplementation(realRead);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (q, p) =>
+ /FROM `notification`/u.test(q) ? [] : realRead(q, p),
+ );
+ let created;
+ try {
+ created = await store.create({
+ userId: user.id,
+ value: { title: 'lagged' },
+ });
+ } finally {
+ read.mockRestore();
+ pread.mockRestore();
+ }
+
+ expect(created?.user_id).toBe(user.id);
+ expect(created?.value).toEqual({ title: 'lagged' });
+ });
+
it('rejects a create with no user', async () => {
await expect(store.create({ value: {} })).rejects.toThrow(
'userId is required',
diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js
index 8e8dc2edb..71f7001b0 100644
--- a/src/backend/stores/share/ShareStore.js
+++ b/src/backend/stores/share/ShareStore.js
@@ -53,10 +53,12 @@ const SHARED_IDS_CHUNK_SIZE = 1000;
export class ShareStore extends PuterStore {
// -- Reads --------------------------------------------------------
- async getByUid(uid) {
- const rows = await this.clients.db.read(
+ /** Pass `primary` to read back a row just written; a replica may lag. */
+ async getByUid(uid, { primary = false } = {}) {
+ const rows = await this.#select(
'SELECT * FROM `share` WHERE `uid` = ? LIMIT 1',
[uid],
+ primary,
);
return this.#normalizeRow(rows[0]) ?? null;
}
@@ -623,7 +625,7 @@ export class ShareStore extends PuterStore {
[mode, data, existing[0].uid],
);
return {
- row: await this.getByUid(existing[0].uid),
+ row: await this.getByUid(existing[0].uid, { primary: true }),
created: false,
};
}
@@ -644,7 +646,10 @@ export class ShareStore extends PuterStore {
fsentryId,
],
);
- return { row: await this.getByUid(uid), created: true };
+ return {
+ row: await this.getByUid(uid, { primary: true }),
+ created: true,
+ };
}
async create({ issuerUserId, recipientEmail, data }) {
@@ -660,7 +665,7 @@ export class ShareStore extends PuterStore {
'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, `data`) VALUES (?, ?, ?, ?)',
[uid, issuerUserId, recipientEmail, serialized],
);
- return this.getByUid(uid);
+ return this.getByUid(uid, { primary: true });
}
/**
@@ -731,7 +736,10 @@ export class ShareStore extends PuterStore {
data,
],
);
- return this.getActive({ holderUserId, fsentryId, issuerUserId });
+ return this.getActive(
+ { holderUserId, fsentryId, issuerUserId },
+ { primary: true },
+ );
}
/**
@@ -797,7 +805,10 @@ export class ShareStore extends PuterStore {
data,
],
);
- return this.getActiveGroup({ holderGroupId, fsentryId, issuerUserId });
+ return this.getActiveGroup(
+ { holderGroupId, fsentryId, issuerUserId },
+ { primary: true },
+ );
}
/**
@@ -805,12 +816,17 @@ export class ShareStore extends PuterStore {
* @param {number} input.holderGroupId
* @param {number} input.fsentryId
* @param {number} input.issuerUserId
+ * @param {{ primary?: boolean }} [opts]
*/
- async getActiveGroup({ holderGroupId, fsentryId, issuerUserId }) {
- const rows = await this.clients.db.read(
+ async getActiveGroup(
+ { holderGroupId, fsentryId, issuerUserId },
+ { primary = false } = {},
+ ) {
+ const rows = await this.#select(
'SELECT * FROM `share` WHERE `holder_group_id` = ? AND ' +
'`fsentry_id` = ? AND `issuer_user_id` = ? LIMIT 1',
[holderGroupId, fsentryId, issuerUserId],
+ primary,
);
return this.#normalizeRow(rows[0]) ?? null;
}
@@ -847,12 +863,17 @@ export class ShareStore extends PuterStore {
* @param {number} input.holderUserId
* @param {number} input.fsentryId
* @param {number} input.issuerUserId
+ * @param {{ primary?: boolean }} [opts]
*/
- async getActive({ holderUserId, fsentryId, issuerUserId }) {
- const rows = await this.clients.db.read(
+ async getActive(
+ { holderUserId, fsentryId, issuerUserId },
+ { primary = false } = {},
+ ) {
+ const rows = await this.#select(
'SELECT * FROM `share` WHERE `holder_user_id` = ? AND ' +
'`fsentry_id` = ? AND `issuer_user_id` = ? LIMIT 1',
[holderUserId, fsentryId, issuerUserId],
+ primary,
);
return this.#normalizeRow(rows[0]) ?? null;
}
@@ -905,7 +926,7 @@ export class ShareStore extends PuterStore {
],
);
if ((result?.affectedRows ?? result?.changes ?? 0) === 0) return null;
- return this.getByUid(uid);
+ return this.getByUid(uid, { primary: true });
}
/**
@@ -1037,11 +1058,12 @@ export class ShareStore extends PuterStore {
: this.clients.db.read(sql, params);
}
- async getAnyone(fsentryId) {
- const rows = await this.#readAnyone(
- 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1 LIMIT 1',
- [fsentryId],
- );
+ async getAnyone(fsentryId, { primary = false } = {}) {
+ const sql =
+ 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1 LIMIT 1';
+ const rows = primary
+ ? await this.clients.db.pread(sql, [fsentryId])
+ : await this.#readAnyone(sql, [fsentryId]);
return this.#normalizeRow(rows[0]) ?? null;
}
@@ -1140,7 +1162,7 @@ export class ShareStore extends PuterStore {
issuerUserId,
],
);
- return this.getAnyone(fsentryId);
+ return this.getAnyone(fsentryId, { primary: true });
}
/**
@@ -1231,6 +1253,12 @@ export class ShareStore extends PuterStore {
// -- Internals ----------------------------------------------------
+ #select(sql, params, primary) {
+ return primary
+ ? this.clients.db.pread(sql, params)
+ : this.clients.db.read(sql, params);
+ }
+
/** The recursive walk of a directory's row ids, by parent linkage. */
#subtreeCte() {
return (
diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js
index 225694f03..9aa2d3b84 100644
--- a/src/backend/stores/share/ShareStore.test.js
+++ b/src/backend/stores/share/ShareStore.test.js
@@ -1000,5 +1000,152 @@ describe('ShareStore', () => {
expect(await store.getByUid(mine.row.uid)).toBeNull();
expect(await store.getByUid(theirs.row.uid)).not.toBeNull();
});
+
+ describe('behind a lagging replica', () => {
+ const rawRow = async (uid) =>
+ (
+ await server.clients.db.read(
+ 'SELECT * FROM `share` WHERE `uid` = ?',
+ [uid],
+ )
+ )[0];
+
+ /**
+ * Replica reads of `share` see only `staleRows`. sqlite's pread
+ * delegates to read, so the primary is pinned to the real one.
+ */
+ const lagShares = (staleRows = []) => {
+ const db = server.clients.db;
+ const realRead = db.read.bind(db);
+ const pread = vi
+ .spyOn(db, 'pread')
+ .mockImplementation(realRead);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (q, p) =>
+ /FROM `share`/u.test(q)
+ ? staleRows.map((r) => ({ ...r }))
+ : realRead(q, p),
+ );
+ return () => {
+ read.mockRestore();
+ pread.mockRestore();
+ };
+ };
+
+ it('returns the rows it inserts before the replica has them', async () => {
+ const entry = await makeEntry(issuer);
+ const team = await server.stores.team.create({
+ ownerUserId: issuer.id,
+ name: 'Lagging replica',
+ });
+
+ const restore = lagShares();
+ try {
+ const invite = await store.create({
+ issuerUserId: issuer.id,
+ recipientEmail: `lag-${uuidv4()}@test.local`,
+ data: {},
+ });
+ expect(invite?.issuer_user_id).toBe(issuer.id);
+
+ const pending = await store.upsertPending({
+ issuerUserId: issuer.id,
+ recipientEmail: `lag-${uuidv4()}@test.local`,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ expect(pending.created).toBe(true);
+ expect(pending.row?.mode).toBe('read');
+
+ const active = await store.upsertActive({
+ issuerUserId: issuer.id,
+ holderUserId: holder.id,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ expect(active?.holder_user_id).toBe(holder.id);
+
+ const group = await store.upsertActiveGroup({
+ issuerUserId: issuer.id,
+ holderGroupId: team.id,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ expect(group?.holder_group_id).toBe(team.id);
+
+ const link = await store.upsertAnyone({
+ issuerUserId: issuer.id,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ expect(link?.mode).toBe('read');
+ } finally {
+ restore();
+ }
+ });
+
+ it('returns the row as written, not the replica copy from before', async () => {
+ const entry = await makeEntry(issuer);
+ const email = `lag-${uuidv4()}@test.local`;
+ const { row: invite } = await store.upsertPending({
+ issuerUserId: issuer.id,
+ recipientEmail: email,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ const active = await store.upsertActive({
+ issuerUserId: issuer.id,
+ holderUserId: holder.id,
+ fsentryId: entry.id,
+ mode: 'read',
+ });
+ const claimable = await store.create({
+ issuerUserId: issuer.id,
+ recipientEmail: `lag-${uuidv4()}@test.local`,
+ data: {},
+ });
+
+ let restore = lagShares([await rawRow(invite.uid)]);
+ try {
+ const remoded = await store.upsertPending({
+ issuerUserId: issuer.id,
+ recipientEmail: email,
+ fsentryId: entry.id,
+ mode: 'write',
+ });
+ expect(remoded.created).toBe(false);
+ expect(remoded.row.mode).toBe('write');
+ } finally {
+ restore();
+ }
+
+ restore = lagShares([await rawRow(active.uid)]);
+ try {
+ const upgraded = await store.upsertActive({
+ issuerUserId: issuer.id,
+ holderUserId: holder.id,
+ fsentryId: entry.id,
+ mode: 'write',
+ });
+ expect(upgraded.mode).toBe('write');
+ } finally {
+ restore();
+ }
+
+ restore = lagShares([await rawRow(claimable.uid)]);
+ try {
+ const applied = await store.applyPending({
+ uid: claimable.uid,
+ holderUserId: holder.id,
+ fsentryId: (await makeEntry(issuer)).id,
+ mode: 'read',
+ });
+ expect(applied.holder_user_id).toBe(holder.id);
+ } finally {
+ restore();
+ }
+ });
+ });
});
});
diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts
index a0fb87d78..5e89857fe 100644
--- a/src/backend/stores/subdomain/SubdomainStore.test.ts
+++ b/src/backend/stores/subdomain/SubdomainStore.test.ts
@@ -123,6 +123,37 @@ describe('SubdomainStore cache coherency', () => {
expect(after?.root_dir_id).toBe(2);
});
+ it('update returns and caches the new row before the replica has it', async () => {
+ const userId = await makeUser();
+ const subdomain = `sds-lag-${Math.random().toString(36).slice(2, 8)}`;
+ const row = await store.create({ userId, subdomain, rootDirId: 1 });
+
+ // sqlite's pread delegates to read, so the primary is pinned to the
+ // real one while replica reads of `subdomains` come back empty.
+ const db = server.clients.db;
+ const realRead = db.read.bind(db);
+ const pread = vi.spyOn(db, 'pread').mockImplementation(realRead);
+ const read = vi
+ .spyOn(db, 'read')
+ .mockImplementation(async (q, p) =>
+ /FROM `subdomains`/u.test(q) ? [] : realRead(q, p),
+ );
+ let updated;
+ try {
+ updated = await store.update(
+ row.uuid,
+ { root_dir_id: 2 },
+ { userId },
+ );
+ } finally {
+ read.mockRestore();
+ pread.mockRestore();
+ }
+
+ expect(updated?.root_dir_id).toBe(2);
+ expect((await store.getBySubdomain(subdomain))?.root_dir_id).toBe(2);
+ });
+
it('delete leaves a negative marker rather than a stale row', async () => {
const userId = await makeUser();
const subdomain = `sds-delete-${Math.random().toString(36).slice(2, 8)}`;
diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts
index ff0c130f8..6c3402937 100644
--- a/src/backend/stores/subdomain/SubdomainStore.ts
+++ b/src/backend/stores/subdomain/SubdomainStore.ts
@@ -612,7 +612,7 @@ export class SubdomainStore extends PuterStore {
[...values, ...whereParams],
);
- const after = await this.getByUuid(uuid, { userId });
+ const after = await this.getByUuid(uuid, { userId, primary: true });
if (before?.subdomain && before.subdomain !== after?.subdomain) {
await this.publishCacheKeys({
keys: [this.#cacheKey(before.subdomain)],