@wells-nelson/data-storage 1.0.1 → 1.0.3

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/dist/supabase.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createClient } from '@supabase/supabase-js';
2
1
  /**
3
2
  * PostgREST refuses a schema that isn't exposed in the project's Data API settings (PGRST106,
4
3
  * "Invalid schema: …"). Returns the fix to show instead, or null for other errors.
@@ -24,23 +23,83 @@ export function friendlyError(error, noun, schema = 'public') {
24
23
  return new Error(`That email already has access to this ${noun}.`);
25
24
  return new Error(error.message);
26
25
  }
26
+ /** supabase-js's own session key for a project URL. */
27
+ export function defaultStorageKey(supabaseUrl) {
28
+ try {
29
+ return `sb-${new URL(supabaseUrl).hostname.split('.')[0]}-auth-token`;
30
+ }
31
+ catch {
32
+ return 'sb-auth-token';
33
+ }
34
+ }
35
+ /**
36
+ * Move a session stored under supabase-js's default key to `storageKey` (once, when the new key
37
+ * is empty). The old key is removed: two apps holding the same refresh token would revoke each
38
+ * other's session, so only the first app to start keeps it and the other signs in again.
39
+ */
40
+ export function adoptSession(legacyKey, storageKey) {
41
+ if (legacyKey === storageKey)
42
+ return;
43
+ try {
44
+ if (localStorage.getItem(storageKey) !== null)
45
+ return;
46
+ const value = localStorage.getItem(legacyKey);
47
+ if (value === null)
48
+ return;
49
+ localStorage.setItem(storageKey, value);
50
+ localStorage.removeItem(legacyKey);
51
+ const verifier = localStorage.getItem(`${legacyKey}-code-verifier`);
52
+ if (verifier !== null) {
53
+ localStorage.setItem(`${storageKey}-code-verifier`, verifier);
54
+ localStorage.removeItem(`${legacyKey}-code-verifier`);
55
+ }
56
+ }
57
+ catch {
58
+ /* storage unavailable: nothing to move */
59
+ }
60
+ }
27
61
  /** Supabase client plus the members API, with table and RPC names from `members`. */
28
62
  export function createSupabaseApi(env, members, options = {}) {
29
63
  let client = null;
64
+ let loading = null;
30
65
  const schema = options.schema ?? 'public';
66
+ const legacyKey = defaultStorageKey(env.supabaseUrl);
67
+ const storageKey = options.storageKey ?? legacyKey;
31
68
  const { table, containerField, membersRpc, roles, noun } = members;
32
69
  const rpcParam = members.rpcParam ?? `p_${containerField}`;
33
- function getClient() {
70
+ function loadClient() {
34
71
  if (!env.cloudConfigured)
35
- return null;
36
- client ??= createClient(env.supabaseUrl, env.supabaseAnonKey, {
37
- db: { schema },
38
- auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
39
- });
40
- return client;
72
+ return Promise.resolve(null);
73
+ loading ??= (async () => {
74
+ const { createClient } = await import('@supabase/supabase-js');
75
+ adoptSession(legacyKey, storageKey);
76
+ client = createClient(env.supabaseUrl, env.supabaseAnonKey, {
77
+ db: { schema },
78
+ auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, storageKey }
79
+ });
80
+ return client;
81
+ })();
82
+ // A failed download (offline on first use) can be retried later.
83
+ loading.catch(() => (loading = null));
84
+ return loading;
85
+ }
86
+ function mayHaveSession() {
87
+ if (!env.cloudConfigured)
88
+ return false;
89
+ try {
90
+ if (localStorage.getItem(storageKey) !== null || localStorage.getItem(legacyKey) !== null)
91
+ return true;
92
+ }
93
+ catch {
94
+ /* no storage: only a redirect can carry a session */
95
+ }
96
+ if (typeof location === 'undefined')
97
+ return false;
98
+ const params = new URLSearchParams(location.search);
99
+ return params.has('code') || params.has('error_description') || /access_token|error_description/.test(location.hash);
41
100
  }
42
- function requireClient() {
43
- const sb = getClient();
101
+ async function requireClient() {
102
+ const sb = await loadClient();
44
103
  if (!sb)
45
104
  throw new Error('Cloud sync is not configured.');
46
105
  return sb;
@@ -52,9 +111,12 @@ export function createSupabaseApi(env, members, options = {}) {
52
111
  return {
53
112
  configured: env.cloudConfigured,
54
113
  schema,
55
- getClient,
114
+ storageKey,
115
+ loadClient,
116
+ getClient: () => client,
117
+ mayHaveSession,
56
118
  async fetchMembers(containerId) {
57
- const { data, error } = await requireClient().rpc(membersRpc, { [rpcParam]: containerId });
119
+ const { data, error } = await (await requireClient()).rpc(membersRpc, { [rpcParam]: containerId });
58
120
  fail(error);
59
121
  return (data ?? []);
60
122
  },
@@ -62,21 +124,21 @@ export function createSupabaseApi(env, members, options = {}) {
62
124
  const row = { [containerField]: containerId, email: email.trim().toLowerCase() };
63
125
  if (roles)
64
126
  row.role = role ?? 'viewer';
65
- const { error } = await requireClient().from(table).insert(row);
127
+ const { error } = await (await requireClient()).from(table).insert(row);
66
128
  fail(error);
67
129
  },
68
130
  async setMemberRole(memberId, role) {
69
131
  if (!roles)
70
132
  throw new Error('Roles are not enabled.');
71
- const { error } = await requireClient().from(table).update({ role }).eq('id', memberId);
133
+ const { error } = await (await requireClient()).from(table).update({ role }).eq('id', memberId);
72
134
  fail(error);
73
135
  },
74
136
  async removeMember(memberId) {
75
- const { error } = await requireClient().from(table).delete().eq('id', memberId);
137
+ const { error } = await (await requireClient()).from(table).delete().eq('id', memberId);
76
138
  fail(error);
77
139
  },
78
140
  async leave(containerId, email) {
79
- const { error } = await requireClient()
141
+ const { error } = await (await requireClient())
80
142
  .from(table)
81
143
  .delete()
82
144
  .eq(containerField, containerId)
@@ -85,7 +147,7 @@ export function createSupabaseApi(env, members, options = {}) {
85
147
  },
86
148
  async fetchMyMemberships(email) {
87
149
  const columns = roles ? `${containerField}, role` : containerField;
88
- const { data, error } = await requireClient().from(table).select(columns).eq('email', email.toLowerCase());
150
+ const { data, error } = await (await requireClient()).from(table).select(columns).eq('email', email.toLowerCase());
89
151
  fail(error);
90
152
  const rows = (data ?? []);
91
153
  return new Map(rows.map((r) => [r[containerField], roles ? r.role : null]));
package/dist/types.d.ts CHANGED
@@ -8,11 +8,15 @@ export interface DocBase {
8
8
  id: string;
9
9
  updated_at: number;
10
10
  }
11
- /** Collection name → document type, e.g. `{ projects: ProjectDoc; tasks: TaskDoc }`. */
12
- export type DocMap = {
13
- [name: string]: DocBase;
11
+ /**
12
+ * Collection name → document type, e.g. `{ projects: ProjectDoc; tasks: TaskDoc }`, as a `type`
13
+ * alias or an `interface`. Generics constrain with `D extends DocMap<D>` (every key of `D` is a
14
+ * document), which an interface satisfies; a plain `DocMap` is any such map.
15
+ */
16
+ export type DocMap<D = Record<string, DocBase>> = {
17
+ [K in keyof D]: DocBase;
14
18
  };
15
- export type CollectionName<D extends DocMap> = keyof D & string;
19
+ export type CollectionName<D extends DocMap<D>> = keyof D & string;
16
20
  /** Where a container lives: only on this device, or replicated to Supabase. */
17
21
  export type Location = 'local' | 'cloud';
18
22
  export type Role = 'viewer' | 'editor' | 'owner';
@@ -25,7 +29,7 @@ export interface Nouns {
25
29
  * The shape of an app's data. A **container** (a project, a list…) owns every other row through
26
30
  * `containerField` and is the unit that gets shared, synced and moved between data sets.
27
31
  */
28
- export interface StoreSchema<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> {
32
+ export interface StoreSchema<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> {
29
33
  /** Every collection, parents before children (the container first). */
30
34
  collections: readonly CollectionName<D>[];
31
35
  schemas: {
@@ -39,4 +43,4 @@ export interface StoreSchema<D extends DocMap, C extends CollectionName<D> = Col
39
43
  indexContainerField?: boolean;
40
44
  }
41
45
  /** The container a document belongs to (containers are their own container). */
42
- export declare function containerIdOf<D extends DocMap>(schema: Pick<StoreSchema<D>, 'container' | 'containerField'>, collection: CollectionName<D>, doc: object): string;
46
+ export declare function containerIdOf<D extends DocMap<D>>(schema: Pick<StoreSchema<D>, 'container' | 'containerField'>, collection: CollectionName<D>, doc: object): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wells-nelson/data-storage",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Offline-first storage for Wells Nelson apps: RxDB + TanStack DB data sets, Supabase sync of shared containers, tombstones, device prefs and a SQL migration generator.",
5
5
  "license": "MIT",
6
6
  "repository": {