Skip to content
Merged
4 changes: 2 additions & 2 deletions apps/otel-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
},
"devDependencies": {
"@types/node": "^22.19.19",
"@vitest/coverage-v8": "^5.0.0",
"@vitest/coverage-v8": "^5.0.5",
"oxfmt": "^0.74.0",
"typescript": "~7.0.2",
"vitest": "^5.0.0"
"vitest": "^5.0.5"
},
"packageManager": "pnpm@11.11.0"
}
2 changes: 1 addition & 1 deletion apps/zbugs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"vite": "^8.3.4",
"vite-bundle-analyzer": "^1.3.8",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zero-cache": "workspace:*",
"zqlite": "workspace:*"
},
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@types/node": "^22.19.19",
"@vitest/browser": "^5.0.0",
"@vitest/browser-playwright": "^5.0.0",
"@vitest/coverage-v8": "^5.0.0",
"@vitest/browser": "^5.0.5",
"@vitest/browser-playwright": "^5.0.5",
"@vitest/coverage-v8": "^5.0.5",
"baseline-browser-mapping": "^2.11.29",
"oxfmt": "^0.74.0",
"oxlint": "^1.80.0",
Expand All @@ -42,7 +42,7 @@
"tsnapi": "^0.3.3",
"turbo": "^2.9.14",
"typescript": "~7.0.2",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zx": "^8.8.5"
},
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/analyze-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"otel": "workspace:*",
"oxfmt": "^0.74.0",
"shared": "workspace:*",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zero-cache": "workspace:*",
"zero-client": "workspace:*",
"zero-protocol": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/ast-to-zql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"devDependencies": {
"@faker-js/faker": "^10.6.0",
"shared": "workspace:*",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zero-cache": "workspace:*",
"zero-protocol": "workspace:*",
"zero-schema": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/datadog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"oxfmt": "^0.74.0",
"shared": "workspace:*",
"typescript": "~7.0.2",
"vitest": "^5.0.0"
"vitest": "^5.0.5"
},
"packageManager": "pnpm@11.11.0"
}
2 changes: 1 addition & 1 deletion packages/replicache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"react-native": "^0.88.0",
"shared": "workspace:*",
"typescript": "~7.0.2",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"xbytes": "^1.9.1"
},
"peerDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions packages/replicache/src/deleted-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ describe('normalizeDeletedClients', () => {

expect(result).toEqual(input);
});

test('does not need Array.prototype.toSorted', () => {
// Hermes, the React Native JS engine, does not implement it.
const toSorted = Object.getOwnPropertyDescriptor(
Array.prototype,
'toSorted',
);
Reflect.deleteProperty(Array.prototype, 'toSorted');
try {
const input: DeletedClients = [
{clientGroupID: 'group-b', clientID: 'client-1'},
{clientGroupID: 'group-a', clientID: 'client-2'},
{clientGroupID: 'group-b', clientID: 'client-1'},
];
expect(normalizeDeletedClients(input)).toEqual([
{clientGroupID: 'group-a', clientID: 'client-2'},
{clientGroupID: 'group-b', clientID: 'client-1'},
]);
} finally {
if (toSorted) {
Object.defineProperty(Array.prototype, 'toSorted', toSorted);
}
}
});
});

describe('mergeDeletedClients', () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/replicache/src/deleted-clients.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {toSorted} from '../../shared/src/iterables.ts';
import {stringCompare} from '../../shared/src/string-compare.ts';
import * as v from '../../shared/src/valita.ts';
import type {Read, Write} from './dag/store.ts';
Expand Down Expand Up @@ -43,9 +44,10 @@ function compare(a: ClientIDPair, b: ClientIDPair): number {
export function normalizeDeletedClients(
deletedClients: DeletedClients,
): DeletedClients {
return deletedClients
.toSorted(compare)
.filter((item, i, arr) => i === 0 || compare(item, arr[i - 1]) !== 0);
// Not Array.prototype.toSorted: Hermes (React Native) does not implement it.
return toSorted(deletedClients, compare).filter(
(item, i, arr) => i === 0 || compare(item, arr[i - 1]) !== 0,
);
}

export function mergeDeletedClients(
Expand Down
20 changes: 15 additions & 5 deletions packages/replicache/src/kv/sqlite-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,23 @@ export class SQLiteStore implements Store {
const {lock, db} = this.#entry;
// Wait for all readers and writers to finish.
const writeRelease = await lock.write();
try {
// An overlapping close() of this store may have finished while this one
// was waiting for the lock. Decrementing again would drop a reference
// held by another store on the same file and close it underneath that
// store.
if (this.#closed) {
return;
}

// Handle reference counting for shared stores - only close database
// when this is the last store instance using it
decrementStoreRefCount(this.#filename, db);
// Handle reference counting for shared stores - only close database
// when this is the last store instance using it
decrementStoreRefCount(this.#filename, db);

this.#closed = true;
writeRelease();
this.#closed = true;
} finally {
writeRelease();
}
}

get closed(): boolean {
Expand Down
18 changes: 18 additions & 0 deletions packages/replicache/src/kv/zero-sqlite/store.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ test('entry rejects NULL keys', async () => {
}
});

test('overlapping close() calls keep other stores on the same file open', async () => {
// Both calls pass the closed check before either takes the lock, so the
// second one must not drop the reference held by the other store.
const name = 'zero-sqlite-overlapping-close';
const closing = createStore(name);
const other = createStore(name);
await withWrite(other, async wt => {
await wt.put('k', 'v');
});

await Promise.all([closing.close(), closing.close()]);

await withRead(other, async rt => {
expect(await rt.get('k')).toBe('v');
});
await other.close();
});

test('setupDatabase pragmas take effect on a real SQLite connection', () => {
// The pragma tests in sqlite-store.test.node.ts only record the SQL sent to a
// fake delegate. SQLite ignores page_size silently when it is issued too late,
Expand Down
49 changes: 49 additions & 0 deletions packages/replicache/src/replicache-impl.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {LogLevel, LogSink} from '@rocicorp/logger';
import {resolver} from '@rocicorp/resolver';
import {describe, expect, onTestFinished, test, vi} from 'vitest';
import {MemStore} from './kv/mem-store.ts';
import type {StoreProvider} from './kv/store.ts';
import {refresh} from './persist/refresh.ts';
import {ReplicacheImpl} from './replicache-impl.ts';
import type {ReplicacheOptions, ZeroOption} from './replicache-options.ts';
Expand Down Expand Up @@ -102,4 +104,51 @@ describe('ReplicacheImpl', () => {

await impl.close();
});

test('a store that fails to close does not block reopening the same name', async () => {
// `#open` waits on `closingInstances` for a previous instance of the same
// name. If that instance's `close()` throws before it clears its entry,
// the wait never ends and every later instance of the name hangs in open.
const name = 'test-close-failure-does-not-block-reopen';
const closeError = new Error('the store could not be closed');

let failClose = true;
const inners: MemStore[] = [];
const kvStore: StoreProvider = {
create: storeName => {
const inner = new MemStore(storeName);
inners.push(inner);
return {
read: () => inner.read(),
write: () => inner.write(),
close: () => (failClose ? Promise.reject(closeError) : inner.close()),
get closed() {
return inner.closed;
},
};
},
drop: () => Promise.resolve(),
};

const impl = new ReplicacheImpl(
{name, kvStore},
{enablePullAndPushInOpen: false},
);
await impl.clientGroupID;
await expect(impl.close()).rejects.toBe(closeError);

failClose = false;
let opened = false;
const next = new ReplicacheImpl(
{name, kvStore},
{enablePullAndPushInOpen: false},
);
void next.clientGroupID.then(() => {
opened = true;
});
await vi.waitFor(() => expect(opened).toBe(true));

await next.close();
await Promise.all(inners.map(inner => inner.close()));
});
});
39 changes: 22 additions & 17 deletions packages/replicache/src/replicache-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,28 +952,33 @@ export class ReplicacheImpl<MD extends MutatorDefs = {}> {
const {promise, resolve} = resolver();
closingInstances.set(this.name, promise);

this.#closeAbortController.abort();
// A later instance of this name waits on `promise` in `#open`, so it must
// settle even if closing a store throws.
try {
this.#closeAbortController.abort();

getBrowserGlobal('document')?.removeEventListener(
'visibilitychange',
this.#onVisibilityChange,
);
getBrowserGlobal('document')?.removeEventListener(
'visibilitychange',
this.#onVisibilityChange,
);

await Promise.race([this.#ready, this.#openFailed.promise]);
const closingPromises = [
this.memdag.close(),
this.perdag.close(),
this.#idbDatabases.close(),
];
await Promise.race([this.#ready, this.#openFailed.promise]);
const closingPromises = [
this.memdag.close(),
this.perdag.close(),
this.#idbDatabases.close(),
];

this.#pullConnectionLoop.close();
this.#pushConnectionLoop.close();
this.#pullConnectionLoop.close();
this.#pushConnectionLoop.close();

this.#subscriptions.clear();
this.#subscriptions.clear();

await Promise.all(closingPromises);
closingInstances.delete(this.name);
resolve();
await Promise.all(closingPromises);
} finally {
closingInstances.delete(this.name);
resolve();
}
}

async maybeEndPull(syncHead: Hash, requestID: string): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"playwright": "^1.60.0",
"type-fest": "^4.41.0",
"typescript": "~7.0.2",
"vitest": "^5.0.0"
"vitest": "^5.0.5"
},
"packageManager": "pnpm@11.11.0"
}
2 changes: 1 addition & 1 deletion packages/z2s/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"pg": "^8.20.0",
"postgres": "3.4.7",
"shared": "workspace:*",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zero-cache": "workspace:*",
"zero-protocol": "workspace:*",
"zero-schema": "workspace:*",
Expand Down
6 changes: 3 additions & 3 deletions packages/z2s/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,18 +727,18 @@ function selectIdent(server: ServerSpec, column: QualifiedColumn): SQLQuery {
if (!serverColumnSchema.isEnum) {
let needsNormalization = false;
switch (serverType) {
case 'timestamptz':
// @ts-expect-error Fallthrough intended
case 'timetz':
// @ts-expect-error Fallthrough intended
case 'time with time zone':
needsNormalization = true;
// fallthrough

case 'date':
case 'time':
case 'time without time zone':
case 'time with time zone':
case 'timestamp':
case 'timestamp without time zone':
case 'timestamptz':
case 'timestamp with time zone': {
// EXTRACT(EPOCH FROM timetz) can be negative when the UTC offset is
// positive (e.g. 01:00+02 = 23:00 UTC prev day = -3600s). Wrap with
Expand Down
2 changes: 1 addition & 1 deletion packages/zero-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"shared": "workspace:*",
"testcontainers": "^10.28.0",
"typescript": "~7.0.2",
"vitest": "^5.0.0",
"vitest": "^5.0.5",
"zero-permissions": "workspace:*",
"zero-protocol": "workspace:*",
"zero-schema": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions packages/zero-cache/src/config/zero-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,8 +1256,8 @@ export const zeroOptions = {
``,
`When enabled, lag reports are used to gate readiness of servers coming online by waiting`,
`for lag to fall below the {bold ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS}.`,
`As such, the reporting interval determines the time for which a new server waits for`,
`the next report to determine readiness.`,
`A report is requested as soon as a new server has caught up, so its readiness does not`,
`wait for the next report interval.`,
``,
`A negative or 0 value disables lag reporting and the associated readiness gating.`,
``,
Expand Down
Loading
Loading