@koolbase/core 10.0.0
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/README.md +19 -0
- package/dist/cjs/analytics.d.ts +24 -0
- package/dist/cjs/analytics.js +110 -0
- package/dist/cjs/auth-errors.d.ts +117 -0
- package/dist/cjs/auth-errors.js +250 -0
- package/dist/cjs/auth.d.ts +213 -0
- package/dist/cjs/auth.js +813 -0
- package/dist/cjs/cache-store.d.ts +50 -0
- package/dist/cjs/cache-store.js +194 -0
- package/dist/cjs/conflict.d.ts +80 -0
- package/dist/cjs/conflict.js +84 -0
- package/dist/cjs/database-errors.d.ts +101 -0
- package/dist/cjs/database-errors.js +200 -0
- package/dist/cjs/database.d.ts +298 -0
- package/dist/cjs/database.js +852 -0
- package/dist/cjs/device-id.d.ts +1 -0
- package/dist/cjs/device-id.js +57 -0
- package/dist/cjs/device-metadata.d.ts +36 -0
- package/dist/cjs/device-metadata.js +94 -0
- package/dist/cjs/errors.d.ts +64 -0
- package/dist/cjs/errors.js +85 -0
- package/dist/cjs/flags.d.ts +15 -0
- package/dist/cjs/flags.js +76 -0
- package/dist/cjs/function-errors.d.ts +51 -0
- package/dist/cjs/function-errors.js +103 -0
- package/dist/cjs/functions.d.ts +15 -0
- package/dist/cjs/functions.js +83 -0
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.js +56 -0
- package/dist/cjs/messaging.d.ts +13 -0
- package/dist/cjs/messaging.js +36 -0
- package/dist/cjs/offline-state.d.ts +97 -0
- package/dist/cjs/offline-state.js +198 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pending-write.d.ts +47 -0
- package/dist/cjs/pending-write.js +22 -0
- package/dist/cjs/platform.d.ts +51 -0
- package/dist/cjs/platform.js +41 -0
- package/dist/cjs/realtime.d.ts +44 -0
- package/dist/cjs/realtime.js +195 -0
- package/dist/cjs/record.d.ts +2 -0
- package/dist/cjs/record.js +23 -0
- package/dist/cjs/shared.d.ts +9 -0
- package/dist/cjs/shared.js +43 -0
- package/dist/cjs/storage-errors.d.ts +163 -0
- package/dist/cjs/storage-errors.js +253 -0
- package/dist/cjs/storage.d.ts +198 -0
- package/dist/cjs/storage.js +451 -0
- package/dist/cjs/sync-engine.d.ts +30 -0
- package/dist/cjs/sync-engine.js +286 -0
- package/dist/cjs/types.d.ts +487 -0
- package/dist/cjs/types.js +40 -0
- package/dist/esm/analytics.d.ts +24 -0
- package/dist/esm/analytics.js +106 -0
- package/dist/esm/auth-errors.d.ts +117 -0
- package/dist/esm/auth-errors.js +222 -0
- package/dist/esm/auth.d.ts +213 -0
- package/dist/esm/auth.js +809 -0
- package/dist/esm/cache-store.d.ts +50 -0
- package/dist/esm/cache-store.js +179 -0
- package/dist/esm/conflict.d.ts +80 -0
- package/dist/esm/conflict.js +80 -0
- package/dist/esm/database-errors.d.ts +101 -0
- package/dist/esm/database-errors.js +189 -0
- package/dist/esm/database.d.ts +298 -0
- package/dist/esm/database.js +848 -0
- package/dist/esm/device-id.d.ts +1 -0
- package/dist/esm/device-id.js +54 -0
- package/dist/esm/device-metadata.d.ts +36 -0
- package/dist/esm/device-metadata.js +90 -0
- package/dist/esm/errors.d.ts +64 -0
- package/dist/esm/errors.js +79 -0
- package/dist/esm/flags.d.ts +15 -0
- package/dist/esm/flags.js +72 -0
- package/dist/esm/function-errors.d.ts +51 -0
- package/dist/esm/function-errors.js +93 -0
- package/dist/esm/functions.d.ts +15 -0
- package/dist/esm/functions.js +79 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.js +25 -0
- package/dist/esm/messaging.d.ts +13 -0
- package/dist/esm/messaging.js +32 -0
- package/dist/esm/offline-state.d.ts +97 -0
- package/dist/esm/offline-state.js +189 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/pending-write.d.ts +47 -0
- package/dist/esm/pending-write.js +19 -0
- package/dist/esm/platform.d.ts +51 -0
- package/dist/esm/platform.js +36 -0
- package/dist/esm/realtime.d.ts +44 -0
- package/dist/esm/realtime.js +191 -0
- package/dist/esm/record.d.ts +2 -0
- package/dist/esm/record.js +20 -0
- package/dist/esm/shared.d.ts +9 -0
- package/dist/esm/shared.js +39 -0
- package/dist/esm/storage-errors.d.ts +163 -0
- package/dist/esm/storage-errors.js +239 -0
- package/dist/esm/storage.d.ts +198 -0
- package/dist/esm/storage.js +447 -0
- package/dist/esm/sync-engine.d.ts +30 -0
- package/dist/esm/sync-engine.js +282 -0
- package/dist/esm/types.d.ts +487 -0
- package/dist/esm/types.js +37 -0
- package/package.json +45 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export * from './conflict.js';
|
|
4
|
+
export * from './pending-write.js';
|
|
5
|
+
export * from './function-errors.js';
|
|
6
|
+
export * from './auth-errors.js';
|
|
7
|
+
export * from './database-errors.js';
|
|
8
|
+
export * from './storage-errors.js';
|
|
9
|
+
export { KoolbaseAuth } from './auth.js';
|
|
10
|
+
export { KoolbaseDatabase } from './database.js';
|
|
11
|
+
export { KoolbaseFlags } from './flags.js';
|
|
12
|
+
export { KoolbaseFunctions } from './functions.js';
|
|
13
|
+
export { KoolbaseRealtime } from './realtime.js';
|
|
14
|
+
export { KoolbaseStorage } from './storage.js';
|
|
15
|
+
export { KoolbaseAnalytics } from './analytics.js';
|
|
16
|
+
export { KoolbaseMessaging } from './messaging.js';
|
|
17
|
+
export type { RegisterTokenOptions } from './messaging.js';
|
|
18
|
+
export { getOrCreateDeviceId } from './device-id.js';
|
|
19
|
+
export { koolbaseSdkVersion } from './device-metadata.js';
|
|
20
|
+
export { RestoreResult } from './types.js';
|
|
21
|
+
export type { AuthStateListener, FetchLike, KoolbaseAuthStorage } from './types.js';
|
|
22
|
+
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformInfo, } from './platform.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @koolbase/core — the SDK's shared behaviour, with no host assumptions.
|
|
2
|
+
//
|
|
3
|
+
// A platform package (react-native, js) supplies a PlatformAdapter and an auth
|
|
4
|
+
// storage, composes the clients, and re-exports this surface. Application
|
|
5
|
+
// code installs the platform package, never this one directly.
|
|
6
|
+
export * from './types.js';
|
|
7
|
+
export * from './errors.js';
|
|
8
|
+
export * from './conflict.js';
|
|
9
|
+
export * from './pending-write.js';
|
|
10
|
+
export * from './function-errors.js';
|
|
11
|
+
export * from './auth-errors.js';
|
|
12
|
+
export * from './database-errors.js';
|
|
13
|
+
export * from './storage-errors.js';
|
|
14
|
+
export { KoolbaseAuth } from './auth.js';
|
|
15
|
+
export { KoolbaseDatabase } from './database.js';
|
|
16
|
+
export { KoolbaseFlags } from './flags.js';
|
|
17
|
+
export { KoolbaseFunctions } from './functions.js';
|
|
18
|
+
export { KoolbaseRealtime } from './realtime.js';
|
|
19
|
+
export { KoolbaseStorage } from './storage.js';
|
|
20
|
+
export { KoolbaseAnalytics } from './analytics.js';
|
|
21
|
+
export { KoolbaseMessaging } from './messaging.js';
|
|
22
|
+
export { getOrCreateDeviceId } from './device-id.js';
|
|
23
|
+
export { koolbaseSdkVersion } from './device-metadata.js';
|
|
24
|
+
export { RestoreResult } from './types.js';
|
|
25
|
+
export { setPlatform, getPlatform, memoryPlatform, } from './platform.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { KoolbaseConfig } from './types.js';
|
|
2
|
+
export interface RegisterTokenOptions {
|
|
3
|
+
token: string;
|
|
4
|
+
platform: 'android' | 'ios';
|
|
5
|
+
userId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class KoolbaseMessaging {
|
|
8
|
+
private config;
|
|
9
|
+
private deviceId;
|
|
10
|
+
constructor(config: KoolbaseConfig);
|
|
11
|
+
setDeviceId(deviceId: string): void;
|
|
12
|
+
registerToken(options: RegisterTokenOptions): Promise<boolean>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ─── KoolbaseMessaging ────────────────────────────────────────────────────────
|
|
2
|
+
export class KoolbaseMessaging {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.deviceId = '';
|
|
5
|
+
this.config = config;
|
|
6
|
+
}
|
|
7
|
+
setDeviceId(deviceId) {
|
|
8
|
+
this.deviceId = deviceId;
|
|
9
|
+
}
|
|
10
|
+
// ─── Register token ───────────────────────────────────────────────────────
|
|
11
|
+
async registerToken(options) {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(`${this.config.baseUrl}/v1/messaging/register`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
'x-api-key': this.config.publicKey,
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
device_id: this.deviceId,
|
|
21
|
+
token: options.token,
|
|
22
|
+
platform: options.platform,
|
|
23
|
+
...(options.userId && { user_id: options.userId }),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
return response.ok;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export declare function recordKey(userId: string, collection: string, recordId: string): string;
|
|
2
|
+
/** A write waiting to be sent. */
|
|
3
|
+
export interface QueuedWrite {
|
|
4
|
+
id: string;
|
|
5
|
+
operation: 'insert' | 'update' | 'delete';
|
|
6
|
+
collection: string;
|
|
7
|
+
recordId?: string;
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* The record as the client last saw it, for update and delete.
|
|
11
|
+
*
|
|
12
|
+
* Copied in at enqueue time rather than looked up at replay: the record cache
|
|
13
|
+
* can be evicted or invalidated in between, and a write whose baseline
|
|
14
|
+
* depends on something else surviving is not durable. From here on the write
|
|
15
|
+
* is self-contained.
|
|
16
|
+
*/
|
|
17
|
+
baseline?: Record<string, unknown>;
|
|
18
|
+
/** The revision that baseline carried, sent so the server can refuse atomically. */
|
|
19
|
+
baseRevision?: number;
|
|
20
|
+
retries: number;
|
|
21
|
+
enqueuedAt: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Why a write is waiting for a decision.
|
|
25
|
+
*
|
|
26
|
+
* Kept distinct because they are different situations and an app showing them
|
|
27
|
+
* to someone should say different things. One means two people changed the same
|
|
28
|
+
* thing; the other means we never knew what the change was based on, so there is
|
|
29
|
+
* nothing to compare it against.
|
|
30
|
+
*/
|
|
31
|
+
export type ConflictReason =
|
|
32
|
+
/** The record moved between the change being made and the queue reaching it. */
|
|
33
|
+
'concurrent_modification'
|
|
34
|
+
/**
|
|
35
|
+
* Queued by a version of this SDK that did not record what the change was
|
|
36
|
+
* composed against. It cannot be replayed safely — there is nothing to check
|
|
37
|
+
* it against — so it waits rather than overwriting whatever is there now.
|
|
38
|
+
*/
|
|
39
|
+
| 'baseline_unavailable'
|
|
40
|
+
/**
|
|
41
|
+
* The server refused the write for a reason retrying cannot change — the data
|
|
42
|
+
* no longer satisfies the collection's rules, the record is gone, the caller
|
|
43
|
+
* is not permitted, a unique value is taken.
|
|
44
|
+
*
|
|
45
|
+
* Held rather than retried or dropped. Retrying sends identical bytes to
|
|
46
|
+
* identical rules; dropping loses a change the user believes is saved. Neither
|
|
47
|
+
* tells them anything.
|
|
48
|
+
*/
|
|
49
|
+
| 'rejected';
|
|
50
|
+
/** A write the server would not apply, held until someone decides. */
|
|
51
|
+
export interface QueuedConflict {
|
|
52
|
+
reason: ConflictReason;
|
|
53
|
+
id: string;
|
|
54
|
+
operation: 'insert' | 'update' | 'delete';
|
|
55
|
+
collection: string;
|
|
56
|
+
recordId: string;
|
|
57
|
+
local?: Record<string, unknown>;
|
|
58
|
+
baseline?: Record<string, unknown>;
|
|
59
|
+
server?: Record<string, unknown>;
|
|
60
|
+
baseRevision?: number;
|
|
61
|
+
serverRevision?: number;
|
|
62
|
+
/** What the server said, when it refused for a terminal reason. */
|
|
63
|
+
message?: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
export interface OfflineState {
|
|
67
|
+
pending: QueuedWrite[];
|
|
68
|
+
conflicts: QueuedConflict[];
|
|
69
|
+
}
|
|
70
|
+
export declare class OfflineStateTooLargeError extends Error {
|
|
71
|
+
constructor(bytes: number);
|
|
72
|
+
}
|
|
73
|
+
export declare function readOfflineState(userId: string): Promise<OfflineState>;
|
|
74
|
+
/**
|
|
75
|
+
* Reads, mutates, and writes the state under the user's lock.
|
|
76
|
+
*
|
|
77
|
+
* Every mutation goes through here. A caller that reads and writes separately
|
|
78
|
+
* reintroduces the race this exists to prevent.
|
|
79
|
+
*/
|
|
80
|
+
export declare function mutateOfflineState(userId: string, mutate: (state: OfflineState) => void): Promise<void>;
|
|
81
|
+
/** Adds a write to the queue, under the user's lock. */
|
|
82
|
+
export declare function queueWrite(userId: string, write: Omit<QueuedWrite, 'retries' | 'enqueuedAt'>): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Moves writes queued by an earlier version into the current state.
|
|
85
|
+
*
|
|
86
|
+
* Runs once, before any replay, and never contacts the network. Migration that
|
|
87
|
+
* depended on connectivity would give the same input two different outcomes
|
|
88
|
+
* depending on whether the device happened to be online at startup — which is
|
|
89
|
+
* how a bug becomes unreproducible.
|
|
90
|
+
*
|
|
91
|
+
* Inserts carry everything they need and simply move across. Updates and
|
|
92
|
+
* deletes do not: they were queued before baselines were recorded, so replaying
|
|
93
|
+
* one would apply it blindly and overwrite whatever changed in the meantime.
|
|
94
|
+
* They are preserved as waiting for a decision instead — the change is not lost,
|
|
95
|
+
* and nothing is written on a guess.
|
|
96
|
+
*/
|
|
97
|
+
export declare function migrateLegacyQueue(userId: string): Promise<void>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { getPlatform } from './platform.js';
|
|
2
|
+
import { shared } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* The offline system's correctness-critical state: writes waiting to be sent,
|
|
5
|
+
* and writes the server refused because the record moved underneath them.
|
|
6
|
+
*
|
|
7
|
+
* Both live under one key so moving between them is a single write. A conflict
|
|
8
|
+
* recorded without removing the pending write would replay and be refused
|
|
9
|
+
* forever; a pending write removed without recording the conflict would lose a
|
|
10
|
+
* change the user believes is saved. Two keys admit both outcomes.
|
|
11
|
+
*
|
|
12
|
+
* Records are cached separately, per record. Putting them here would let a
|
|
13
|
+
* growing cache push this value past a storage ceiling and make the queue
|
|
14
|
+
* unreadable — losing the correctness-critical state to something incidental.
|
|
15
|
+
*/
|
|
16
|
+
const VERSION = 'v1';
|
|
17
|
+
function stateKey(userId) {
|
|
18
|
+
return `koolbase:${VERSION}:${userId}:offline-state`;
|
|
19
|
+
}
|
|
20
|
+
export function recordKey(userId, collection, recordId) {
|
|
21
|
+
return `koolbase:${VERSION}:${userId}:record:${collection}:${recordId}`;
|
|
22
|
+
}
|
|
23
|
+
const EMPTY = { pending: [], conflicts: [] };
|
|
24
|
+
/**
|
|
25
|
+
* A conservative ceiling, below the lowest per-value limit any supported
|
|
26
|
+
* AsyncStorage implementation imposes.
|
|
27
|
+
*
|
|
28
|
+
* Not a claim about a platform maximum — a boundary that leaves headroom for
|
|
29
|
+
* rewriting and migration. A queue that cannot be written is a queue that
|
|
30
|
+
* silently stops accepting work, so the limit is enforced with an error rather
|
|
31
|
+
* than discovered.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_STATE_BYTES = 1000000;
|
|
34
|
+
export class OfflineStateTooLargeError extends Error {
|
|
35
|
+
constructor(bytes) {
|
|
36
|
+
super(`Offline state is ${bytes} bytes, above the ${MAX_STATE_BYTES} byte limit. ` +
|
|
37
|
+
'Sync or resolve what is queued before making more offline changes.');
|
|
38
|
+
this.name = 'OfflineStateTooLargeError';
|
|
39
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Serialises access per user.
|
|
44
|
+
*
|
|
45
|
+
* A single setItem is one storage operation, but read-modify-write is not: two
|
|
46
|
+
* callers can both read, both modify, and the second write silently discards
|
|
47
|
+
* the first. Two components queueing a write in the same tick is enough. The
|
|
48
|
+
* lock is in-process — one JavaScript runtime is the assumption every React
|
|
49
|
+
* Native app satisfies, and the SDK does not promise more.
|
|
50
|
+
*/
|
|
51
|
+
/** UTF-8 byte length, without depending on TextEncoder being present. */
|
|
52
|
+
function byteLength(s) {
|
|
53
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
54
|
+
return new TextEncoder().encode(s).length;
|
|
55
|
+
}
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
for (let i = 0; i < s.length; i++) {
|
|
58
|
+
const c = s.codePointAt(i);
|
|
59
|
+
if (c > 0xffff)
|
|
60
|
+
i++; // surrogate pair, counted once
|
|
61
|
+
bytes += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
62
|
+
}
|
|
63
|
+
return bytes;
|
|
64
|
+
}
|
|
65
|
+
const locks = () => shared('locks', () => new Map());
|
|
66
|
+
async function withLock(userId, fn) {
|
|
67
|
+
const previous = locks().get(userId) ?? Promise.resolve();
|
|
68
|
+
let release = () => { };
|
|
69
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
70
|
+
locks().set(userId, previous.then(() => next));
|
|
71
|
+
await previous;
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
release();
|
|
77
|
+
if (locks().get(userId) === next)
|
|
78
|
+
locks().delete(userId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function readOfflineState(userId) {
|
|
82
|
+
try {
|
|
83
|
+
const raw = await getPlatform().storage.getItem(stateKey(userId));
|
|
84
|
+
if (!raw)
|
|
85
|
+
return { ...EMPTY, pending: [], conflicts: [] };
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
return { pending: parsed.pending ?? [], conflicts: parsed.conflicts ?? [] };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Unreadable state is treated as empty rather than throwing: an app that
|
|
91
|
+
// cannot start is worse than one that has lost a queue it could not read
|
|
92
|
+
// anyway. The write path's size guard is what keeps this from happening.
|
|
93
|
+
return { pending: [], conflicts: [] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Reads, mutates, and writes the state under the user's lock.
|
|
98
|
+
*
|
|
99
|
+
* Every mutation goes through here. A caller that reads and writes separately
|
|
100
|
+
* reintroduces the race this exists to prevent.
|
|
101
|
+
*/
|
|
102
|
+
export async function mutateOfflineState(userId, mutate) {
|
|
103
|
+
await withLock(userId, async () => {
|
|
104
|
+
const state = await readOfflineState(userId);
|
|
105
|
+
mutate(state);
|
|
106
|
+
const serialised = JSON.stringify(state);
|
|
107
|
+
// Bytes, not characters. String length undercounts anything outside ASCII —
|
|
108
|
+
// an accented name, any non-Latin script — so a limit measured in
|
|
109
|
+
// characters permits more than it claims, which is the wrong direction for
|
|
110
|
+
// a safety boundary. TextEncoder is not guaranteed in every React Native
|
|
111
|
+
// runtime, so fall back to a byte count computed directly.
|
|
112
|
+
const bytes = byteLength(serialised);
|
|
113
|
+
if (bytes > MAX_STATE_BYTES) {
|
|
114
|
+
throw new OfflineStateTooLargeError(bytes);
|
|
115
|
+
}
|
|
116
|
+
await getPlatform().storage.setItem(stateKey(userId), serialised);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Adds a write to the queue, under the user's lock. */
|
|
120
|
+
export async function queueWrite(userId, write) {
|
|
121
|
+
await mutateOfflineState(userId, (state) => {
|
|
122
|
+
state.pending.push({
|
|
123
|
+
...write,
|
|
124
|
+
retries: 0,
|
|
125
|
+
enqueuedAt: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const LEGACY_QUEUE_VERSION = 'v1';
|
|
130
|
+
function legacyQueueKey(userId) {
|
|
131
|
+
return `koolbase:${LEGACY_QUEUE_VERSION}:${userId}:write_queue`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Moves writes queued by an earlier version into the current state.
|
|
135
|
+
*
|
|
136
|
+
* Runs once, before any replay, and never contacts the network. Migration that
|
|
137
|
+
* depended on connectivity would give the same input two different outcomes
|
|
138
|
+
* depending on whether the device happened to be online at startup — which is
|
|
139
|
+
* how a bug becomes unreproducible.
|
|
140
|
+
*
|
|
141
|
+
* Inserts carry everything they need and simply move across. Updates and
|
|
142
|
+
* deletes do not: they were queued before baselines were recorded, so replaying
|
|
143
|
+
* one would apply it blindly and overwrite whatever changed in the meantime.
|
|
144
|
+
* They are preserved as waiting for a decision instead — the change is not lost,
|
|
145
|
+
* and nothing is written on a guess.
|
|
146
|
+
*/
|
|
147
|
+
export async function migrateLegacyQueue(userId) {
|
|
148
|
+
const raw = await getPlatform().storage.getItem(legacyQueueKey(userId));
|
|
149
|
+
if (!raw)
|
|
150
|
+
return;
|
|
151
|
+
let legacy = [];
|
|
152
|
+
try {
|
|
153
|
+
legacy = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Unreadable: nothing recoverable, and leaving the key would retry forever.
|
|
157
|
+
await getPlatform().storage.removeItem(legacyQueueKey(userId));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
await mutateOfflineState(userId, (state) => {
|
|
161
|
+
for (const w of legacy) {
|
|
162
|
+
const operation = w.type;
|
|
163
|
+
if (operation === 'insert') {
|
|
164
|
+
state.pending.push({
|
|
165
|
+
id: w.id,
|
|
166
|
+
operation: 'insert',
|
|
167
|
+
collection: w.collection,
|
|
168
|
+
recordId: w.recordId,
|
|
169
|
+
data: w.data,
|
|
170
|
+
retries: w.retries ?? 0,
|
|
171
|
+
enqueuedAt: w.createdAt ?? new Date().toISOString(),
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!w.recordId)
|
|
176
|
+
continue;
|
|
177
|
+
state.conflicts.push({
|
|
178
|
+
id: w.id,
|
|
179
|
+
reason: 'baseline_unavailable',
|
|
180
|
+
operation,
|
|
181
|
+
collection: w.collection ?? '',
|
|
182
|
+
recordId: w.recordId,
|
|
183
|
+
local: w.data,
|
|
184
|
+
createdAt: w.createdAt ?? new Date().toISOString(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
await getPlatform().storage.removeItem(legacyQueueKey(userId));
|
|
189
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { QueuedWrite } from './offline-state.js';
|
|
2
|
+
/**
|
|
3
|
+
* A change made offline, waiting to be sent.
|
|
4
|
+
*
|
|
5
|
+
* The counterpart to KoolbaseConflict, one step earlier in the lifecycle: a
|
|
6
|
+
* conflict is a write the server refused; a pending write is one the server has
|
|
7
|
+
* not seen yet. Both are durable state an app should surface — a queue nobody
|
|
8
|
+
* can see accumulates invisibly, and the changes it holds feel saved to the
|
|
9
|
+
* user while existing only on this device.
|
|
10
|
+
*
|
|
11
|
+
* The case that makes this API matter: logout. Queues are per-user and survive
|
|
12
|
+
* logout by design, so a user signing out with pending writes walks away
|
|
13
|
+
* believing their edits saved — and they sync whenever that user next logs in
|
|
14
|
+
* on this device, which may be never. Warn before logout:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const pending = await Koolbase.db.pendingWrites();
|
|
18
|
+
* if (pending.length > 0) {
|
|
19
|
+
* // "You have 3 unsynced changes. Sync now, or they wait until you
|
|
20
|
+
* // next sign in on this device."
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* This is a snapshot, not a live handle — re-read after a sync. Per-user:
|
|
25
|
+
* another account's queue on this device is not visible here.
|
|
26
|
+
*/
|
|
27
|
+
export interface PendingWrite {
|
|
28
|
+
id: string;
|
|
29
|
+
operation: 'insert' | 'update' | 'delete';
|
|
30
|
+
collection: string;
|
|
31
|
+
/** Absent for an insert the server has not yet assigned. */
|
|
32
|
+
recordId?: string;
|
|
33
|
+
/** What the user changed. Absent for a delete. */
|
|
34
|
+
data?: Record<string, unknown>;
|
|
35
|
+
enqueuedAt: string;
|
|
36
|
+
/** Failed send attempts so far. A count, not a policy — nothing drops it. */
|
|
37
|
+
attempts: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Maps the stored write to its public shape, field by field.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not a spread: the stored write carries baseline and baseRevision
|
|
43
|
+
* — replay mechanics, not contract. A spread would leak whatever the storage
|
|
44
|
+
* shape grows next; naming each field means new internals stay internal until
|
|
45
|
+
* someone chooses otherwise.
|
|
46
|
+
*/
|
|
47
|
+
export declare function toPendingWrite(w: QueuedWrite): PendingWrite;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps the stored write to its public shape, field by field.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a spread: the stored write carries baseline and baseRevision
|
|
5
|
+
* — replay mechanics, not contract. A spread would leak whatever the storage
|
|
6
|
+
* shape grows next; naming each field means new internals stay internal until
|
|
7
|
+
* someone chooses otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export function toPendingWrite(w) {
|
|
10
|
+
return {
|
|
11
|
+
id: w.id,
|
|
12
|
+
operation: w.operation,
|
|
13
|
+
collection: w.collection,
|
|
14
|
+
recordId: w.recordId,
|
|
15
|
+
data: w.data,
|
|
16
|
+
enqueuedAt: w.enqueuedAt,
|
|
17
|
+
attempts: w.retries,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface PlatformStorage {
|
|
2
|
+
getItem(key: string): Promise<string | null>;
|
|
3
|
+
setItem(key: string, value: string): Promise<void>;
|
|
4
|
+
removeItem(key: string): Promise<void>;
|
|
5
|
+
getAllKeys(): Promise<string[]>;
|
|
6
|
+
}
|
|
7
|
+
export interface PlatformNetwork {
|
|
8
|
+
/**
|
|
9
|
+
* Subscribe to connectivity changes. The callback receives true when the
|
|
10
|
+
* host believes it is online. Treated as a hint: the sync engine still
|
|
11
|
+
* discovers reachability by trying, and a host that reports online while
|
|
12
|
+
* Koolbase is unreachable is handled the same as any failed request.
|
|
13
|
+
*/
|
|
14
|
+
onChange(callback: (online: boolean) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
export interface PlatformLifecycle {
|
|
17
|
+
/** Subscribe to the app moving to the background. Used to flush analytics. */
|
|
18
|
+
onBackground(callback: () => void): () => void;
|
|
19
|
+
}
|
|
20
|
+
export interface PlatformInfo {
|
|
21
|
+
/** e.g. 'ios', 'android', 'web' */
|
|
22
|
+
os: string;
|
|
23
|
+
/** OS or browser version, as a string; '' if unknown. */
|
|
24
|
+
version: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PlatformAdapter {
|
|
27
|
+
storage: PlatformStorage;
|
|
28
|
+
network: PlatformNetwork;
|
|
29
|
+
lifecycle: PlatformLifecycle;
|
|
30
|
+
info: PlatformInfo;
|
|
31
|
+
/**
|
|
32
|
+
* The host's best persistent store for the auth session, or null if it has
|
|
33
|
+
* none worth the name. Used only when the app injects nothing through
|
|
34
|
+
* KoolbaseConfig.authStorage. React Native answers with the keychain when
|
|
35
|
+
* it is installed; a browser answers with IndexedDB and says so in its
|
|
36
|
+
* README, since nothing JavaScript can read is secure against XSS.
|
|
37
|
+
*/
|
|
38
|
+
authStorage(): import('./types.js').KoolbaseAuthStorage | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* An adapter that persists nothing beyond the process and reports the
|
|
42
|
+
* network as always up. The default until a platform sets its own — which
|
|
43
|
+
* makes tests run without a module mapper, and makes forgetting to call
|
|
44
|
+
* setPlatform an obvious failure (nothing survives a restart) rather than a
|
|
45
|
+
* crash.
|
|
46
|
+
*/
|
|
47
|
+
export declare function memoryPlatform(): PlatformAdapter;
|
|
48
|
+
/** Install the host platform. Called once by the platform package's initialize. */
|
|
49
|
+
export declare function setPlatform(adapter: PlatformAdapter): void;
|
|
50
|
+
/** The installed platform. Always returns something; see memoryPlatform. */
|
|
51
|
+
export declare function getPlatform(): PlatformAdapter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Shared across module instances; see shared.ts for why.
|
|
2
|
+
import { shared, setShared } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* An adapter that persists nothing beyond the process and reports the
|
|
5
|
+
* network as always up. The default until a platform sets its own — which
|
|
6
|
+
* makes tests run without a module mapper, and makes forgetting to call
|
|
7
|
+
* setPlatform an obvious failure (nothing survives a restart) rather than a
|
|
8
|
+
* crash.
|
|
9
|
+
*/
|
|
10
|
+
export function memoryPlatform() {
|
|
11
|
+
const store = new Map();
|
|
12
|
+
return {
|
|
13
|
+
storage: {
|
|
14
|
+
getItem: async (k) => store.get(k) ?? null,
|
|
15
|
+
setItem: async (k, v) => { store.set(k, v); },
|
|
16
|
+
removeItem: async (k) => { store.delete(k); },
|
|
17
|
+
getAllKeys: async () => Array.from(store.keys()),
|
|
18
|
+
},
|
|
19
|
+
network: {
|
|
20
|
+
onChange: () => () => { },
|
|
21
|
+
},
|
|
22
|
+
lifecycle: {
|
|
23
|
+
onBackground: () => () => { },
|
|
24
|
+
},
|
|
25
|
+
info: { os: 'memory', version: '' },
|
|
26
|
+
authStorage: () => null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Install the host platform. Called once by the platform package's initialize. */
|
|
30
|
+
export function setPlatform(adapter) {
|
|
31
|
+
setShared('platform', adapter);
|
|
32
|
+
}
|
|
33
|
+
/** The installed platform. Always returns something; see memoryPlatform. */
|
|
34
|
+
export function getPlatform() {
|
|
35
|
+
return shared('platform', memoryPlatform);
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { KoolbaseConfig, RealtimeCallback } from './types.js';
|
|
2
|
+
type TokenProvider = () => Promise<string | null>;
|
|
3
|
+
export declare class KoolbaseRealtime {
|
|
4
|
+
private config;
|
|
5
|
+
private getToken;
|
|
6
|
+
private ws;
|
|
7
|
+
private projectId;
|
|
8
|
+
private listeners;
|
|
9
|
+
private reconnectAttempts;
|
|
10
|
+
private reconnectTimer;
|
|
11
|
+
private connecting;
|
|
12
|
+
/**
|
|
13
|
+
* Identifies whose cache a seen record belongs in.
|
|
14
|
+
*
|
|
15
|
+
* The record cache is keyed by user, so without this a watched record would
|
|
16
|
+
* be filed under the wrong key — or under 'anonymous', which is worse than
|
|
17
|
+
* not caching at all: a baseline stored where it will never be read.
|
|
18
|
+
*/
|
|
19
|
+
private getUserId?;
|
|
20
|
+
constructor(config: KoolbaseConfig, getToken: TokenProvider, getUserId?: () => string | null);
|
|
21
|
+
/** Files a record seen over the socket, if we know whose it is. */
|
|
22
|
+
private cacheSeenRecord;
|
|
23
|
+
private forgetSeenRecord;
|
|
24
|
+
subscribe(collection: string, callback: RealtimeCallback): () => void;
|
|
25
|
+
private connect;
|
|
26
|
+
private sendSubscribe;
|
|
27
|
+
private sendUnsubscribe;
|
|
28
|
+
/**
|
|
29
|
+
* Reconnects with backoff, rather than every three seconds forever.
|
|
30
|
+
*
|
|
31
|
+
* A fixed interval is fine while a connection is merely interrupted and
|
|
32
|
+
* costly when it is not: a device with no network, a wrong URL, or a session
|
|
33
|
+
* the server will not accept retried indefinitely, draining battery and data
|
|
34
|
+
* the user cannot see or stop.
|
|
35
|
+
*
|
|
36
|
+
* Doubling from three seconds to a minute keeps a brief interruption
|
|
37
|
+
* recovering quickly while a lasting one settles into an interval that costs
|
|
38
|
+
* almost nothing. The counter resets when a connection opens, so a flaky link
|
|
39
|
+
* does not accumulate delay.
|
|
40
|
+
*/
|
|
41
|
+
private scheduleReconnect;
|
|
42
|
+
disconnect(): void;
|
|
43
|
+
}
|
|
44
|
+
export {};
|