create-pyric 0.1.0-alpha.14 → 0.1.0-alpha.16
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/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { onValue, ref, serverTimestamp, set } from 'firebase/database';
|
|
1
|
+
import { onDisconnect, onValue, ref, serverTimestamp, set } from 'firebase/database';
|
|
2
2
|
import { auth, rtdb } from '../firebase/app';
|
|
3
3
|
import { asUserId, type AuthUser, type PresenceEntry, type PresenceRecord, ServiceError } from '../firebase/types';
|
|
4
4
|
import { requireUid } from './firestore-helpers';
|
|
@@ -24,19 +24,15 @@ export class PresenceService {
|
|
|
24
24
|
* `/presence/{uid}` the first time a user comes online, which is what
|
|
25
25
|
* fires the `onPresenceOnline` Cloud Function.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* under `vite dev` we rely on the explicit `goOffline` writes below.
|
|
27
|
+
* Register `onDisconnect` before publishing the online state so production
|
|
28
|
+
* and the local sandbox both flip the node to offline on a clean lifecycle
|
|
29
|
+
* boundary. The explicit `goOffline` method remains the user-driven path.
|
|
31
30
|
*/
|
|
32
31
|
async goOnline(user: AuthUser): Promise<void> {
|
|
33
32
|
const uid = requireUid(user.uid);
|
|
34
33
|
try {
|
|
35
34
|
const reference = presenceRef(uid);
|
|
36
|
-
|
|
37
|
-
const { onDisconnect } = await import('firebase/database');
|
|
38
|
-
await onDisconnect(reference).set({ state: 'offline', displayName: user.displayName, at: serverTimestamp() });
|
|
39
|
-
}
|
|
35
|
+
await onDisconnect(reference).set({ state: 'offline', displayName: user.displayName, at: serverTimestamp() });
|
|
40
36
|
await set(reference, { state: 'online', displayName: user.displayName, at: serverTimestamp() });
|
|
41
37
|
} catch (error) {
|
|
42
38
|
throw mapRtdbError(error);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplicates post-sign-in provisioning per user while keeping failures
|
|
3
|
+
* observable. Concurrent callers share one in-flight attempt, so a failure
|
|
4
|
+
* reaches every caller instead of being masked by an "already started"
|
|
5
|
+
* short-circuit. A failed attempt is forgotten, letting the next sign-in
|
|
6
|
+
* retry from scratch; a successful attempt stays cached so provisioning
|
|
7
|
+
* runs once per user.
|
|
8
|
+
*/
|
|
9
|
+
export const createProvisioner = <TUser extends { uid: string }>(
|
|
10
|
+
provision: (user: TUser) => Promise<void>,
|
|
11
|
+
): ((user: TUser) => Promise<void>) => {
|
|
12
|
+
const attempts = new Map<string, Promise<void>>();
|
|
13
|
+
return (user) => {
|
|
14
|
+
const inFlight = attempts.get(user.uid);
|
|
15
|
+
if (inFlight) return inFlight;
|
|
16
|
+
const attempt = provision(user).catch((error: unknown) => {
|
|
17
|
+
attempts.delete(user.uid);
|
|
18
|
+
throw error;
|
|
19
|
+
});
|
|
20
|
+
attempts.set(user.uid, attempt);
|
|
21
|
+
return attempt;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FirebaseAuthService } from '@/services/auth-service';
|
|
2
|
+
import { createProvisioner } from '@/services/provisioning';
|
|
2
3
|
import { ConversationService } from '@/services/conversation-service';
|
|
3
4
|
import { MessageService } from '@/services/message-service';
|
|
4
5
|
import { UserService } from '@/services/user-service';
|
|
@@ -42,29 +43,27 @@ export const createFirebaseChatGateway = (): ChatPageServices => {
|
|
|
42
43
|
const users = new UserService();
|
|
43
44
|
const presence = new PresenceService();
|
|
44
45
|
const notifications = new NotificationService();
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
provisioned.add(user.uid);
|
|
50
|
-
try {
|
|
51
|
-
await users.provision(user);
|
|
52
|
-
await presence.goOnline(user);
|
|
53
|
-
} catch (error) {
|
|
54
|
-
provisioned.delete(user.uid);
|
|
55
|
-
throw error;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
46
|
+
const provision = createProvisioner(async (user: AuthUser) => {
|
|
47
|
+
await users.provision(user);
|
|
48
|
+
await presence.goOnline(user);
|
|
49
|
+
});
|
|
58
50
|
|
|
59
51
|
return {
|
|
60
52
|
auth: {
|
|
61
53
|
currentUser: () => toUiUser(auth.currentUser()),
|
|
54
|
+
// Auth state reports the signed-in user as soon as Firebase does; a
|
|
55
|
+
// provisioning failure must not masquerade as "signed out". The signIn
|
|
56
|
+
// path awaits the same shared attempt, so its failure surfaces in the
|
|
57
|
+
// sign-in error UI rather than only here.
|
|
62
58
|
observe: (callback) => auth.observe((user) => {
|
|
63
59
|
if (!user) {
|
|
64
60
|
callback(null);
|
|
65
61
|
return;
|
|
66
62
|
}
|
|
67
|
-
|
|
63
|
+
callback(toUiUser(user));
|
|
64
|
+
provision(user).catch((error: unknown) => {
|
|
65
|
+
console.error('Post-sign-in provisioning failed; the user profile or presence may be missing.', error);
|
|
66
|
+
});
|
|
68
67
|
}),
|
|
69
68
|
signIn: async () => {
|
|
70
69
|
const signedIn = await auth.signIn();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createProvisioner } from '../src/services/provisioning.ts';
|
|
4
|
+
|
|
5
|
+
const user = { uid: 'user-1' };
|
|
6
|
+
|
|
7
|
+
test('concurrent callers share one attempt and both observe its failure', async () => {
|
|
8
|
+
let runs = 0;
|
|
9
|
+
let reject: (reason: Error) => void = () => undefined;
|
|
10
|
+
const provision = createProvisioner(() => {
|
|
11
|
+
runs += 1;
|
|
12
|
+
return new Promise<void>((_resolve, nextReject) => {
|
|
13
|
+
reject = nextReject;
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const observerAttempt = provision(user);
|
|
18
|
+
const signInAttempt = provision(user);
|
|
19
|
+
assert.equal(runs, 1);
|
|
20
|
+
|
|
21
|
+
reject(new Error('presence write denied'));
|
|
22
|
+
await assert.rejects(observerAttempt, /presence write denied/);
|
|
23
|
+
await assert.rejects(signInAttempt, /presence write denied/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a failed attempt is forgotten so the next sign-in retries', async () => {
|
|
27
|
+
let runs = 0;
|
|
28
|
+
const provision = createProvisioner(() => {
|
|
29
|
+
runs += 1;
|
|
30
|
+
return runs === 1 ? Promise.reject(new Error('offline')) : Promise.resolve();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await assert.rejects(provision(user), /offline/);
|
|
34
|
+
await provision(user);
|
|
35
|
+
assert.equal(runs, 2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a successful attempt is cached per user', async () => {
|
|
39
|
+
let runs = 0;
|
|
40
|
+
const provision = createProvisioner(() => {
|
|
41
|
+
runs += 1;
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await provision(user);
|
|
46
|
+
await provision(user);
|
|
47
|
+
await provision({ uid: 'user-2' });
|
|
48
|
+
assert.equal(runs, 2);
|
|
49
|
+
});
|