@almadar/shell-browser 0.0.0-stage → 0.1.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.
@@ -0,0 +1,45 @@
1
+ import { Auth } from 'firebase/auth';
2
+ import { JsonObject } from '@almadar/core';
3
+
4
+ interface BrowserAuthConfig {
5
+ /** A distinct name per signed-in surface, so two apps on one page never share a session. */
6
+ appName: string;
7
+ apiKey: string;
8
+ projectId: string;
9
+ authDomain?: string;
10
+ /** Identity Platform tenant the app's users belong to. */
11
+ tenantId?: string;
12
+ /** `host:port` of the Auth emulator. */
13
+ emulatorHost?: string;
14
+ /** Where the session lives: `local` (survives reloads, the default) or `memory` (this page only). */
15
+ persistence?: 'local' | 'memory';
16
+ }
17
+ interface SignedInUser {
18
+ uid: string;
19
+ email?: string;
20
+ name?: string;
21
+ photoURL?: string;
22
+ /** The ID token's claims (custom claims such as `role` included). */
23
+ claims: JsonObject;
24
+ }
25
+ /** One sign-in surface: who is signed in, the ways to sign in, and the ID token every request carries. */
26
+ interface BrowserAuth {
27
+ readonly auth: Auth;
28
+ currentUid(): Promise<string | null>;
29
+ signInWithCustomToken(customToken: string): Promise<SignedInUser>;
30
+ signInWithEmail(email: string, password: string): Promise<SignedInUser>;
31
+ signUpWithEmail(email: string, password: string, displayName?: string): Promise<SignedInUser>;
32
+ signInWithGoogle(): Promise<SignedInUser>;
33
+ sendSignInLinkToEmail(email: string, returnUrl: string): Promise<void>;
34
+ isSignInWithEmailLink(link: string): boolean;
35
+ signInWithEmailLink(email: string, link: string): Promise<SignedInUser>;
36
+ /** The current user's ID token (refreshed when near expiry), or undefined when signed out. */
37
+ idToken(): Promise<string | undefined>;
38
+ /** Each change of the signed-in user. `onError` receives a failure to resolve a user's claims. */
39
+ onUserChanged(listener: (user: SignedInUser | null) => void, onError?: (error: Error) => void): () => void;
40
+ signOut(): Promise<void>;
41
+ }
42
+ declare function connectAuth(config: BrowserAuthConfig): Promise<BrowserAuth>;
43
+
44
+ export { connectAuth };
45
+ export type { BrowserAuth, BrowserAuthConfig, SignedInUser };
@@ -0,0 +1,107 @@
1
+ // src/auth/index.ts
2
+ import { initializeApp, getApps } from "firebase/app";
3
+ import {
4
+ GoogleAuthProvider,
5
+ browserLocalPersistence,
6
+ connectAuthEmulator,
7
+ createUserWithEmailAndPassword,
8
+ getAuth,
9
+ inMemoryPersistence,
10
+ isSignInWithEmailLink,
11
+ onIdTokenChanged,
12
+ sendSignInLinkToEmail,
13
+ setPersistence,
14
+ signInWithCustomToken,
15
+ signInWithEmailAndPassword,
16
+ signInWithEmailLink,
17
+ signInWithPopup,
18
+ signOut,
19
+ updateProfile
20
+ } from "firebase/auth";
21
+ import { z } from "zod";
22
+ import { JsonValueSchema } from "@almadar/core";
23
+ var ClaimsSchema = z.record(z.string(), JsonValueSchema);
24
+ async function signedIn(user) {
25
+ const { claims } = await user.getIdTokenResult();
26
+ return {
27
+ uid: user.uid,
28
+ ...user.email !== null ? { email: user.email } : {},
29
+ ...user.displayName !== null ? { name: user.displayName } : {},
30
+ ...user.photoURL !== null ? { photoURL: user.photoURL } : {},
31
+ claims: ClaimsSchema.parse(JSON.parse(JSON.stringify(claims)))
32
+ };
33
+ }
34
+ function appFor(config) {
35
+ const existing = getApps().find((app) => app.name === config.appName);
36
+ return existing ?? initializeApp(
37
+ { apiKey: config.apiKey, projectId: config.projectId, ...config.authDomain !== void 0 ? { authDomain: config.authDomain } : {} },
38
+ config.appName
39
+ );
40
+ }
41
+ async function connectAuth(config) {
42
+ const auth = getAuth(appFor(config));
43
+ if (config.tenantId !== void 0) auth.tenantId = config.tenantId;
44
+ if (config.emulatorHost !== void 0 && auth.emulatorConfig === null) {
45
+ connectAuthEmulator(auth, `http://${config.emulatorHost}`, { disableWarnings: true });
46
+ }
47
+ await setPersistence(auth, config.persistence === "memory" ? inMemoryPersistence : browserLocalPersistence);
48
+ return {
49
+ auth,
50
+ async currentUid() {
51
+ await auth.authStateReady();
52
+ return auth.currentUser?.uid ?? null;
53
+ },
54
+ async signInWithCustomToken(customToken) {
55
+ return signedIn((await signInWithCustomToken(auth, customToken)).user);
56
+ },
57
+ async signInWithEmail(email, password) {
58
+ return signedIn((await signInWithEmailAndPassword(auth, email, password)).user);
59
+ },
60
+ async signUpWithEmail(email, password, displayName) {
61
+ const { user } = await createUserWithEmailAndPassword(auth, email, password);
62
+ if (displayName !== void 0) await updateProfile(user, { displayName });
63
+ return signedIn(user);
64
+ },
65
+ async signInWithGoogle() {
66
+ return signedIn((await signInWithPopup(auth, new GoogleAuthProvider())).user);
67
+ },
68
+ async sendSignInLinkToEmail(email, returnUrl) {
69
+ await sendSignInLinkToEmail(auth, email, { url: returnUrl, handleCodeInApp: true });
70
+ },
71
+ isSignInWithEmailLink(link) {
72
+ return isSignInWithEmailLink(auth, link);
73
+ },
74
+ async signInWithEmailLink(email, link) {
75
+ return signedIn((await signInWithEmailLink(auth, email, link)).user);
76
+ },
77
+ async idToken() {
78
+ return auth.currentUser ? auth.currentUser.getIdToken() : void 0;
79
+ },
80
+ onUserChanged(listener, onError) {
81
+ let generation = 0;
82
+ const stop = onIdTokenChanged(auth, (user) => {
83
+ const current = ++generation;
84
+ if (user === null) listener(null);
85
+ else
86
+ void signedIn(user).then(
87
+ (resolved) => {
88
+ if (current === generation) listener(resolved);
89
+ },
90
+ (err) => {
91
+ if (current === generation) onError?.(err instanceof Error ? err : new Error(String(err)));
92
+ }
93
+ );
94
+ });
95
+ return () => {
96
+ generation++;
97
+ stop();
98
+ };
99
+ },
100
+ async signOut() {
101
+ await signOut(auth);
102
+ }
103
+ };
104
+ }
105
+ export {
106
+ connectAuth
107
+ };
@@ -0,0 +1,258 @@
1
+ // src/kv.ts
2
+ function kvNamespace(app, locale, store) {
3
+ return `kv:${app}:${locale}:${store}`;
4
+ }
5
+ function kvDatabaseName(app, locale) {
6
+ return `kv:${app}:${locale}`;
7
+ }
8
+ var KvFailure = class extends Error {
9
+ constructor(kind, message) {
10
+ super(message);
11
+ this.kind = kind;
12
+ }
13
+ kind;
14
+ };
15
+ function fail(kind, message) {
16
+ return { error: { kind, message } };
17
+ }
18
+ function request(req) {
19
+ return new Promise((resolve, reject) => {
20
+ req.onsuccess = () => resolve(req.result);
21
+ req.onerror = () => reject(req.error);
22
+ });
23
+ }
24
+ function done(tx) {
25
+ return new Promise((resolve, reject) => {
26
+ tx.oncomplete = () => resolve();
27
+ tx.onerror = () => reject(tx.error);
28
+ tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
29
+ });
30
+ }
31
+ function openDatabase(factory, name, version, stores) {
32
+ return new Promise((resolve, reject) => {
33
+ const req = version === void 0 ? factory.open(name) : factory.open(name, version);
34
+ req.onupgradeneeded = () => {
35
+ const db = req.result;
36
+ for (const store of stores) {
37
+ if (!db.objectStoreNames.contains(store)) db.createObjectStore(store);
38
+ }
39
+ };
40
+ req.onsuccess = () => resolve(req.result);
41
+ req.onerror = () => reject(req.error);
42
+ req.onblocked = () => reject(new Error("IndexedDB upgrade blocked by another open connection"));
43
+ });
44
+ }
45
+ function isJsonValue(value) {
46
+ if (value === void 0) return false;
47
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
48
+ if (typeof value === "number") return Number.isFinite(value);
49
+ if (Array.isArray(value)) return value.every((item) => isJsonValue(item));
50
+ if (typeof value !== "object") return false;
51
+ return Object.values(value).every((item) => isJsonValue(item));
52
+ }
53
+ function compareUtf8(a, b) {
54
+ const left = Array.from(a);
55
+ const right = Array.from(b);
56
+ const length = Math.min(left.length, right.length);
57
+ for (let i = 0; i < length; i += 1) {
58
+ const diff = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
59
+ if (diff !== 0) return diff;
60
+ }
61
+ return left.length - right.length;
62
+ }
63
+ function storeName(value) {
64
+ if (typeof value !== "string" || value.length === 0) throw new KvFailure("invalid", "kv: the store name must be a non-empty string");
65
+ return value;
66
+ }
67
+ function keyName(value) {
68
+ if (typeof value !== "string") throw new KvFailure("invalid", "kv: the key must be a string");
69
+ return value;
70
+ }
71
+ function jsonValue(value) {
72
+ if (value === void 0 || !isJsonValue(value)) throw new KvFailure("invalid", "kv/put: the value must be JSON");
73
+ return value;
74
+ }
75
+ function scanLimit(value) {
76
+ if (value === void 0 || value === null) return null;
77
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new KvFailure("invalid", "kv/scan: limit must be a non-negative integer");
78
+ return value;
79
+ }
80
+ function batchOps(value) {
81
+ if (!Array.isArray(value)) throw new KvFailure("invalid", "kv/batch: ops must be an array");
82
+ return value.map((item, index) => {
83
+ if (item === null || typeof item !== "object" || Array.isArray(item)) throw new KvFailure("invalid", `kv/batch: op ${index} must be an object`);
84
+ if (item.kind === "put") return { kind: "put", key: keyName(item.key), value: jsonValue(item.value) };
85
+ if (item.kind === "delete") return { kind: "delete", key: keyName(item.key) };
86
+ throw new KvFailure("invalid", `kv/batch: op ${index} has an unknown kind`);
87
+ });
88
+ }
89
+ var KvWebPort = class {
90
+ constructor(options) {
91
+ this.options = options;
92
+ }
93
+ options;
94
+ db = null;
95
+ lane = Promise.resolve();
96
+ closed = false;
97
+ pending = /* @__PURE__ */ new Map();
98
+ factory() {
99
+ return this.options.factory ?? globalThis.indexedDB;
100
+ }
101
+ /** Open the database and create the store when missing, with a version bump, as the persistence adapter did. */
102
+ async handle(store) {
103
+ const factory = this.factory();
104
+ if (!factory) throw new KvFailure("unsupported", "kv: this host has no IndexedDB");
105
+ const name = kvDatabaseName(this.options.app, this.options.locale);
106
+ const settle = this.lane.then(async () => {
107
+ let db = this.db ?? await openDatabase(factory, name, void 0, [store]);
108
+ if (!db.objectStoreNames.contains(store)) {
109
+ const next = db.version + 1;
110
+ db.close();
111
+ db = await openDatabase(factory, name, next, [store]);
112
+ }
113
+ this.db = db;
114
+ });
115
+ this.lane = settle.catch(() => void 0);
116
+ await settle;
117
+ if (this.db === null) throw new KvFailure("io", "kv: the database did not open");
118
+ return this.db;
119
+ }
120
+ async run(op, args) {
121
+ if (this.closed) throw new KvFailure("closed", "kv: the host is closed");
122
+ const store = storeName(args[0]);
123
+ const allow = (mode) => {
124
+ if (this.options.allows && !this.options.allows({ need: "kv", store, mode })) {
125
+ throw new KvFailure("denied", `kv: no ${mode} grant for store "${store}"`);
126
+ }
127
+ };
128
+ switch (op) {
129
+ case "kv/get": {
130
+ const key = keyName(args[1]);
131
+ allow("read");
132
+ const db = await this.handle(store);
133
+ const tx = db.transaction(store, "readonly");
134
+ const objectStore = tx.objectStore(store);
135
+ if (await request(objectStore.count(key)) === 0) return { found: false };
136
+ return { found: true, value: await request(objectStore.get(key)) };
137
+ }
138
+ case "kv/put": {
139
+ const key = keyName(args[1]);
140
+ const value = jsonValue(args[2]);
141
+ allow("write");
142
+ const db = await this.handle(store);
143
+ const tx = db.transaction(store, "readwrite");
144
+ tx.objectStore(store).put(value, key);
145
+ await done(tx);
146
+ return { storedKey: key };
147
+ }
148
+ case "kv/delete": {
149
+ const key = keyName(args[1]);
150
+ allow("write");
151
+ const db = await this.handle(store);
152
+ const tx = db.transaction(store, "readwrite");
153
+ const objectStore = tx.objectStore(store);
154
+ const existed = await request(objectStore.count(key)) > 0;
155
+ objectStore.delete(key);
156
+ await done(tx);
157
+ return { existed };
158
+ }
159
+ case "kv/scan": {
160
+ const prefix = args[1];
161
+ if (typeof prefix !== "string") throw new KvFailure("invalid", "kv/scan: the prefix must be a string");
162
+ const limit = scanLimit(args[2]);
163
+ allow("read");
164
+ const db = await this.handle(store);
165
+ const tx = db.transaction(store, "readonly");
166
+ const found = [];
167
+ await new Promise((resolve, reject) => {
168
+ const cursor = tx.objectStore(store).openCursor(prefix === "" ? void 0 : IDBKeyRange.lowerBound(prefix));
169
+ cursor.onerror = () => reject(cursor.error);
170
+ cursor.onsuccess = () => {
171
+ const at = cursor.result;
172
+ if (!at) return resolve();
173
+ const key = String(at.key);
174
+ if (!key.startsWith(prefix)) return resolve();
175
+ found.push({ key, value: at.value });
176
+ at.continue();
177
+ };
178
+ });
179
+ found.sort((a, b) => compareUtf8(a.key, b.key));
180
+ return { entries: limit === null ? found : found.slice(0, limit) };
181
+ }
182
+ case "kv/batch": {
183
+ const ops = batchOps(args[1]);
184
+ allow("write");
185
+ const db = await this.handle(store);
186
+ if (ops.length === 0) return { applied: 0 };
187
+ const tx = db.transaction(store, "readwrite");
188
+ const objectStore = tx.objectStore(store);
189
+ const finished = done(tx);
190
+ try {
191
+ for (const item of ops) {
192
+ if (item.kind === "put") objectStore.put(item.value, item.key);
193
+ else objectStore.delete(item.key);
194
+ }
195
+ } catch (cause) {
196
+ tx.abort();
197
+ await finished.catch(() => void 0);
198
+ throw cause;
199
+ }
200
+ await finished;
201
+ return { applied: ops.length };
202
+ }
203
+ }
204
+ }
205
+ async answer(op, args) {
206
+ try {
207
+ const work = this.run(op, args);
208
+ const timeoutMs = this.options.timeoutMs;
209
+ if (timeoutMs === void 0) return { ok: await work };
210
+ let timer;
211
+ const limit = new Promise((_, reject) => {
212
+ timer = setTimeout(() => reject(new KvFailure("timeout", `kv: no answer in ${timeoutMs} ms`)), timeoutMs);
213
+ });
214
+ try {
215
+ return { ok: await Promise.race([work, limit]) };
216
+ } finally {
217
+ clearTimeout(timer);
218
+ }
219
+ } catch (cause) {
220
+ if (cause instanceof KvFailure) return fail(cause.kind, cause.message);
221
+ return fail("io", cause instanceof Error ? cause.message : String(cause));
222
+ }
223
+ }
224
+ defer(op, args, key, deliver) {
225
+ if (this.closed) return fail("closed", "kv: the host is closed");
226
+ if (this.pending.has(key)) return fail("invalid", `kv: a call with key "${key}" is already pending`);
227
+ const entry = { cancelled: false };
228
+ this.pending.set(key, entry);
229
+ void this.answer(op, args).then((outcome) => {
230
+ if (this.pending.get(key) === entry) this.pending.delete(key);
231
+ if (!entry.cancelled) deliver(outcome);
232
+ });
233
+ return { ok: null };
234
+ }
235
+ cancel(key) {
236
+ const entry = this.pending.get(key);
237
+ if (!entry) return false;
238
+ entry.cancelled = true;
239
+ this.pending.delete(key);
240
+ return true;
241
+ }
242
+ close() {
243
+ this.closed = true;
244
+ this.pending.clear();
245
+ this.db?.close();
246
+ this.db = null;
247
+ }
248
+ };
249
+ function createKvWebPort(options) {
250
+ return new KvWebPort(options);
251
+ }
252
+
253
+ export {
254
+ kvNamespace,
255
+ kvDatabaseName,
256
+ KvWebPort,
257
+ createKvWebPort
258
+ };
@@ -0,0 +1,33 @@
1
+ // src/page-facts.ts
2
+ var PAGE_FACT_FIELDS = ["hostname", "referrer", "screenWidth"];
3
+ function parsePageFactFields(value) {
4
+ if (!Array.isArray(value) || value.length === 0) {
5
+ return { error: "browser/page-facts: the fields must be a non-empty list of hostname, referrer, screenWidth" };
6
+ }
7
+ const fields = [];
8
+ for (const entry of value) {
9
+ const field = PAGE_FACT_FIELDS.find((known) => known === entry);
10
+ if (field === void 0) {
11
+ return { error: `browser/page-facts: ${String(entry)} is not one of ${PAGE_FACT_FIELDS.join(", ")}` };
12
+ }
13
+ if (fields.includes(field)) return { error: `browser/page-facts: ${field} is listed twice` };
14
+ fields.push(field);
15
+ }
16
+ return { fields };
17
+ }
18
+ function readPageFacts(fields) {
19
+ const facts = { available: false, hostname: "", referrer: "", screenWidth: null };
20
+ if (typeof window === "undefined" || typeof document === "undefined") return facts;
21
+ facts.available = true;
22
+ for (const field of fields) {
23
+ if (field === "hostname") facts.hostname = window.location.hostname;
24
+ else if (field === "referrer") facts.referrer = document.referrer;
25
+ else facts.screenWidth = window.screen?.width ?? null;
26
+ }
27
+ return facts;
28
+ }
29
+
30
+ export {
31
+ parsePageFactFields,
32
+ readPageFacts
33
+ };
@@ -0,0 +1,151 @@
1
+ // src/storage/IndexedDbPersistence.ts
2
+ import { FULL_WRITE_CAPABILITIES } from "@almadar/core";
3
+ var CHANGE_LOG = "__changes";
4
+ function request(req) {
5
+ return new Promise((resolve, reject) => {
6
+ req.onsuccess = () => resolve(req.result);
7
+ req.onerror = () => reject(req.error);
8
+ });
9
+ }
10
+ function done(tx) {
11
+ return new Promise((resolve, reject) => {
12
+ tx.oncomplete = () => resolve();
13
+ tx.onerror = () => reject(tx.error);
14
+ tx.onabort = () => reject(tx.error);
15
+ });
16
+ }
17
+ function openDatabase(factory, name, version, stores) {
18
+ return new Promise((resolve, reject) => {
19
+ const req = version === void 0 ? factory.open(name) : factory.open(name, version);
20
+ req.onupgradeneeded = () => {
21
+ const db = req.result;
22
+ for (const store of stores) {
23
+ if (!db.objectStoreNames.contains(store)) {
24
+ db.createObjectStore(store, store === CHANGE_LOG ? { autoIncrement: true } : { keyPath: "id" });
25
+ }
26
+ }
27
+ };
28
+ req.onsuccess = () => resolve(req.result);
29
+ req.onerror = () => reject(req.error);
30
+ });
31
+ }
32
+ var IndexedDbPersistence = class _IndexedDbPersistence {
33
+ constructor(db, entityTypes, now) {
34
+ this.db = db;
35
+ this.entityTypes = entityTypes;
36
+ this.now = now;
37
+ }
38
+ db;
39
+ entityTypes;
40
+ now;
41
+ /** Open (creating or upgrading) the database so it holds every listed entity type. */
42
+ static async open(options) {
43
+ const factory = options.factory ?? globalThis.indexedDB;
44
+ if (!factory) throw new Error("IndexedDbPersistence: this environment has no IndexedDB");
45
+ const stores = [...options.entityTypes, CHANGE_LOG];
46
+ let db = await openDatabase(factory, options.databaseName, void 0, stores);
47
+ if (stores.some((s) => !db.objectStoreNames.contains(s))) {
48
+ const next = db.version + 1;
49
+ db.close();
50
+ db = await openDatabase(factory, options.databaseName, next, stores);
51
+ }
52
+ return new _IndexedDbPersistence(db, new Set(options.entityTypes), options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()));
53
+ }
54
+ close() {
55
+ this.db.close();
56
+ }
57
+ store(entityType) {
58
+ if (!this.entityTypes.has(entityType)) {
59
+ throw new Error(`IndexedDbPersistence: not opened with entity type "${entityType}"`);
60
+ }
61
+ return entityType;
62
+ }
63
+ async write(entityType, op, id, row) {
64
+ const tx = this.db.transaction([this.store(entityType), CHANGE_LOG], "readwrite");
65
+ if (row) tx.objectStore(entityType).put(row);
66
+ else tx.objectStore(entityType).delete(id);
67
+ const change = { op, entityType, id, row, at: this.now() };
68
+ tx.objectStore(CHANGE_LOG).add(change);
69
+ await done(tx);
70
+ }
71
+ async create(entityType, data) {
72
+ const id = typeof data.id === "string" && data.id.length > 0 ? data.id : crypto.randomUUID();
73
+ await this.write(entityType, "create", id, { ...data, id, updatedAt: this.now() });
74
+ return { id };
75
+ }
76
+ async update(entityType, id, data) {
77
+ const existing = await this.getById(entityType, id);
78
+ if (!existing) return;
79
+ await this.write(entityType, "update", id, { ...existing, ...data, id, updatedAt: this.now() });
80
+ }
81
+ async delete(entityType, id) {
82
+ await this.write(entityType, "delete", id, null);
83
+ }
84
+ async getById(entityType, id) {
85
+ const tx = this.db.transaction(this.store(entityType), "readonly");
86
+ const row = await request(tx.objectStore(entityType).get(id));
87
+ return row ?? null;
88
+ }
89
+ async list(entityType) {
90
+ const tx = this.db.transaction(this.store(entityType), "readonly");
91
+ return request(tx.objectStore(entityType).getAll());
92
+ }
93
+ writeCapabilities(_entityType) {
94
+ return { ...FULL_WRITE_CAPABILITIES, commitRows: true };
95
+ }
96
+ /** The kernel's decided rows, applied in one readwrite transaction only if each row still equals the version it read (EXEC-STORE-5). */
97
+ async commitRows(rows) {
98
+ if (rows.length === 0) return true;
99
+ const stores = [...new Set(rows.map((row) => this.store(row.entityType)))];
100
+ const tx = this.db.transaction([...stores, CHANGE_LOG], "readwrite");
101
+ const log = tx.objectStore(CHANGE_LOG);
102
+ for (const row of rows) {
103
+ const current = await request(tx.objectStore(row.entityType).get(row.id));
104
+ if (!sameRow(current ?? null, row.expected)) {
105
+ tx.abort();
106
+ await done(tx).catch(() => void 0);
107
+ return false;
108
+ }
109
+ }
110
+ for (const row of rows) {
111
+ const objectStore = tx.objectStore(row.entityType);
112
+ if (row.write.kind === "put") {
113
+ const stored = { ...row.write.row, updatedAt: this.now() };
114
+ objectStore.put(stored);
115
+ log.add({ op: row.expected === null ? "create" : "update", entityType: row.entityType, id: row.id, row: stored, at: this.now() });
116
+ } else if (row.write.kind === "delete") {
117
+ objectStore.delete(row.id);
118
+ log.add({ op: "delete", entityType: row.entityType, id: row.id, row: null, at: this.now() });
119
+ }
120
+ }
121
+ await done(tx);
122
+ return true;
123
+ }
124
+ async countRows(entityType) {
125
+ const tx = this.db.transaction(this.store(entityType), "readonly");
126
+ return request(tx.objectStore(entityType).count());
127
+ }
128
+ /** The write log, oldest first — the seam a later `sync` replays. */
129
+ async changes() {
130
+ const tx = this.db.transaction(CHANGE_LOG, "readonly");
131
+ return request(tx.objectStore(CHANGE_LOG).getAll());
132
+ }
133
+ };
134
+ function sameRow(a, b) {
135
+ return sameValue(a, b);
136
+ }
137
+ function sameValue(a, b) {
138
+ if (a === b) return true;
139
+ if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
140
+ if (Array.isArray(a) || Array.isArray(b)) {
141
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => sameValue(item, b[i]));
142
+ }
143
+ if (a === null || b === null || a === void 0 || b === void 0 || typeof a !== "object" || typeof b !== "object") return false;
144
+ const keys = Object.keys(a);
145
+ return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
146
+ }
147
+
148
+ export {
149
+ CHANGE_LOG,
150
+ IndexedDbPersistence
151
+ };
@@ -0,0 +1,5 @@
1
+ export { KvBatchOp, KvHostPort, KvMode, KvOp, KvRequirement, KvWebOptions, KvWebPort, createKvWebPort, kvDatabaseName, kvNamespace } from './kv.js';
2
+ export { parsePageFactFields, readPageFacts } from './page-facts.js';
3
+ export { CHANGE_LOG, ChangeRecord, IndexedDbPersistence, IndexedDbPersistenceOptions } from './storage/index.js';
4
+ import '@almadar/core';
5
+ import '@almadar/core/kernel-wire';
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ CHANGE_LOG,
3
+ IndexedDbPersistence
4
+ } from "./chunk-GXG5PI2M.js";
5
+ import {
6
+ KvWebPort,
7
+ createKvWebPort,
8
+ kvDatabaseName,
9
+ kvNamespace
10
+ } from "./chunk-7SDIX7W6.js";
11
+ import {
12
+ parsePageFactFields,
13
+ readPageFacts
14
+ } from "./chunk-DTZ6GAB4.js";
15
+ export {
16
+ CHANGE_LOG,
17
+ IndexedDbPersistence,
18
+ KvWebPort,
19
+ createKvWebPort,
20
+ kvDatabaseName,
21
+ kvNamespace,
22
+ parsePageFactFields,
23
+ readPageFacts
24
+ };
package/dist/kv.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { JsonValue } from '@almadar/core';
2
+ import { Settled, OsError } from '@almadar/core/kernel-wire';
3
+
4
+ /**
5
+ * The `kv` port of a browser kernel host: `kv/get|put|delete|scan|batch` over IndexedDB, with no Node builtin in sight.
6
+ * The grant check is the Rust `OsGrants` kv requirement, passed in as `allows`; this file only maps a verified request.
7
+ * One IndexedDB database per app and locale (`kv:<app>:<locale>`), one object store per kv store, out-of-line string keys.
8
+ */
9
+
10
+ type KvOp = 'kv/get' | 'kv/put' | 'kv/delete' | 'kv/scan' | 'kv/batch';
11
+ type KvMode = 'read' | 'write';
12
+ /** One thing a `kv/` call needs from the program's grants: the JSON of the Rust `kv` requirement. */
13
+ interface KvRequirement {
14
+ need: 'kv';
15
+ store: string;
16
+ mode: KvMode;
17
+ }
18
+ type KvBatchOp = {
19
+ kind: 'put';
20
+ key: string;
21
+ value: JsonValue;
22
+ } | {
23
+ kind: 'delete';
24
+ key: string;
25
+ };
26
+ interface KvHostPort {
27
+ answer(op: KvOp, args: JsonValue[]): Promise<Settled<JsonValue, OsError>>;
28
+ defer?(op: KvOp, args: JsonValue[], key: string, deliver: (outcome: Settled<JsonValue, OsError>) => void): Settled<null, OsError>;
29
+ cancel?(key: string): boolean;
30
+ close(): void;
31
+ }
32
+ interface KvWebOptions {
33
+ app: string;
34
+ locale: string;
35
+ /** Defaults to `globalThis.indexedDB`; a host without one answers every ask `unsupported`. */
36
+ factory?: IDBFactory;
37
+ /** The wasm grant decision (`OsGrants` kv requirement). Absent means every request is allowed. */
38
+ allows?: (need: KvRequirement) => boolean;
39
+ /** Fails an ask with `timeout` when storage has not answered in this many milliseconds. */
40
+ timeoutMs?: number;
41
+ }
42
+ /** The namespace a kv store lives under: `kv:<app>:<locale>:<store>`. */
43
+ declare function kvNamespace(app: string, locale: string, store: string): string;
44
+ /** The IndexedDB database that holds every kv store of one app and locale. */
45
+ declare function kvDatabaseName(app: string, locale: string): string;
46
+ declare class KvWebPort implements KvHostPort {
47
+ private readonly options;
48
+ private db;
49
+ private lane;
50
+ private closed;
51
+ private readonly pending;
52
+ constructor(options: KvWebOptions);
53
+ private factory;
54
+ /** Open the database and create the store when missing, with a version bump, as the persistence adapter did. */
55
+ private handle;
56
+ private run;
57
+ answer(op: KvOp, args: JsonValue[]): Promise<Settled<JsonValue, OsError>>;
58
+ defer(op: KvOp, args: JsonValue[], key: string, deliver: (outcome: Settled<JsonValue, OsError>) => void): Settled<null, OsError>;
59
+ cancel(key: string): boolean;
60
+ close(): void;
61
+ }
62
+ declare function createKvWebPort(options: KvWebOptions): KvHostPort;
63
+
64
+ export { KvWebPort, createKvWebPort, kvDatabaseName, kvNamespace };
65
+ export type { KvBatchOp, KvHostPort, KvMode, KvOp, KvRequirement, KvWebOptions };
package/dist/kv.js ADDED
@@ -0,0 +1,12 @@
1
+ import {
2
+ KvWebPort,
3
+ createKvWebPort,
4
+ kvDatabaseName,
5
+ kvNamespace
6
+ } from "./chunk-7SDIX7W6.js";
7
+ export {
8
+ KvWebPort,
9
+ createKvWebPort,
10
+ kvDatabaseName,
11
+ kvNamespace
12
+ };
@@ -0,0 +1,16 @@
1
+ import { RuntimeValue, PageFactField, BrowserPageFacts } from '@almadar/core';
2
+
3
+ /**
4
+ * `browser/page-facts`: the closed fact set, the argument check, and the page reader the client handler uses.
5
+ */
6
+
7
+ /** The fields a `browser/page-facts` call asks for, or why the call is refused before anything is read. */
8
+ declare function parsePageFactFields(value: RuntimeValue | undefined): {
9
+ fields: PageFactField[];
10
+ } | {
11
+ error: string;
12
+ };
13
+ /** Read `fields` from the page in list order. No window or document is `available: false`; a throwing getter rejects. */
14
+ declare function readPageFacts(fields: readonly PageFactField[]): BrowserPageFacts;
15
+
16
+ export { parsePageFactFields, readPageFacts };
@@ -0,0 +1,8 @@
1
+ import {
2
+ parsePageFactFields,
3
+ readPageFacts
4
+ } from "./chunk-DTZ6GAB4.js";
5
+ export {
6
+ parsePageFactFields,
7
+ readPageFacts
8
+ };
@@ -0,0 +1,61 @@
1
+ import { EntityRow, PersistenceAdapter, WriteCapabilities, RowCommit } from '@almadar/core';
2
+
3
+ /**
4
+ * IndexedDbPersistence — the browser store for `[persistent: x, browser]` entities.
5
+ *
6
+ * Implements the shared `PersistenceAdapter` contract, so the effect stage runs
7
+ * `fetch`/`persist` against it exactly as a server runs them against Firestore
8
+ * or Postgres. Sync-ready: minted ids are UUIDs, every write stamps `updatedAt`
9
+ * and appends to an ordered change log (`__changes`) a later sync can replay.
10
+ * It answers plain reads; the kernel filters, orders and windows rows itself. Writes arrive as one
11
+ * compare-and-commit of rows the kernel decided (EXEC-STORE-5), so the store holds no query or write semantics.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ /** Object store holding the append-only write log. */
17
+ declare const CHANGE_LOG = "__changes";
18
+ interface ChangeRecord {
19
+ op: "create" | "update" | "delete";
20
+ entityType: string;
21
+ id: string;
22
+ row: EntityRow | null;
23
+ at: string;
24
+ }
25
+ interface IndexedDbPersistenceOptions {
26
+ /** One database per app, e.g. `almadar:<app name>`. */
27
+ databaseName: string;
28
+ /** Every browser-stored entity type this store holds (one object store each). */
29
+ entityTypes: readonly string[];
30
+ /** IndexedDB implementation; defaults to the browser's `indexedDB`. */
31
+ factory?: IDBFactory;
32
+ /** Clock for `updatedAt` / change stamps; defaults to `new Date().toISOString()`. */
33
+ now?: () => string;
34
+ }
35
+ declare class IndexedDbPersistence implements PersistenceAdapter {
36
+ private readonly db;
37
+ private readonly entityTypes;
38
+ private readonly now;
39
+ private constructor();
40
+ /** Open (creating or upgrading) the database so it holds every listed entity type. */
41
+ static open(options: IndexedDbPersistenceOptions): Promise<IndexedDbPersistence>;
42
+ close(): void;
43
+ private store;
44
+ private write;
45
+ create(entityType: string, data: EntityRow): Promise<{
46
+ id: string;
47
+ }>;
48
+ update(entityType: string, id: string, data: EntityRow): Promise<void>;
49
+ delete(entityType: string, id: string): Promise<void>;
50
+ getById(entityType: string, id: string): Promise<EntityRow | null>;
51
+ list(entityType: string): Promise<EntityRow[]>;
52
+ writeCapabilities(_entityType: string): WriteCapabilities;
53
+ /** The kernel's decided rows, applied in one readwrite transaction only if each row still equals the version it read (EXEC-STORE-5). */
54
+ commitRows(rows: readonly RowCommit[]): Promise<boolean>;
55
+ countRows(entityType: string): Promise<number>;
56
+ /** The write log, oldest first — the seam a later `sync` replays. */
57
+ changes(): Promise<ChangeRecord[]>;
58
+ }
59
+
60
+ export { CHANGE_LOG, IndexedDbPersistence };
61
+ export type { ChangeRecord, IndexedDbPersistenceOptions };
@@ -0,0 +1,8 @@
1
+ import {
2
+ CHANGE_LOG,
3
+ IndexedDbPersistence
4
+ } from "../chunk-GXG5PI2M.js";
5
+ export {
6
+ CHANGE_LOG,
7
+ IndexedDbPersistence
8
+ };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@almadar/shell-browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Browser I/O ports for the Almadar TypeScript shell: IndexedDB, page facts and Firebase sign-in SDK.",
5
+ "license": "BUSL-1.1",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./auth": {
15
+ "types": "./dist/auth/index.d.ts",
16
+ "import": "./dist/auth/index.js"
17
+ },
18
+ "./storage": {
19
+ "types": "./dist/storage/index.d.ts",
20
+ "import": "./dist/storage/index.js"
21
+ },
22
+ "./kv": {
23
+ "types": "./dist/kv.d.ts",
24
+ "import": "./dist/kv.js"
25
+ },
26
+ "./page-facts": {
27
+ "types": "./dist/page-facts.d.ts",
28
+ "import": "./dist/page-facts.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "sideEffects": false,
35
+ "dependencies": {
36
+ "@almadar/core": "^10.129.0",
37
+ "firebase": "^11.1.0",
38
+ "zod": "^4.6.7"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.0.0",
42
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
43
+ "tsup": "^9.0.0",
44
+ "vitest": "^4.1.12",
45
+ "fake-indexeddb": "6.2.5"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/almadar-io/orbital-shell-typescript.git",
50
+ "directory": "hosts/browser"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest --run"
56
+ }
6
57
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.