mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 22:32:26 +00:00
fix(stores): read rows back from the primary after writing them
ShareStore (create, upsertPending, upsertActive, upsertActiveGroup, applyPending, upsertAnyone), AppStore (create, createFromOrigin, update, claimOwnership), NotificationStore.create, SubdomainStore.update and NotificationDriver.create returned the row they had just written by reading it back through `db.read`, which goes to the replica. Under replica lag that came back null or stale. Those read-backs now go to the primary, through a `primary` option on the getters. AppStore caches the primary copy on create, and claimOwnership no longer reads through getById, which cached the replica's pre-claim row on a miss.
This commit is contained in:
1 parent
4fca4809b9
commit
c5e37aba73
11 files changed
+432
-32
No files matched your search
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, unknown> | null;
|
||||
try {
|
||||
result = (await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: { value: { type: NOTIF_TYPE, title: 'lagged' } },
|
||||
}),
|
||||
)) as Record<string, unknown> | 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(
|
||||
|
||||
@@ -182,6 +182,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
return this.#toClient(
|
||||
await this.stores.notification.getByUid(uid, {
|
||||
userId: actor.user.id,
|
||||
primary: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in new issue
Block a user