Skip to content

Commit 0f4867b

Browse files
committed
feat(service-messaging): the push device registry, sys_user_device and INotificationService's device members
Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 64d22bf commit 0f4867b

7 files changed

Lines changed: 494 additions & 3 deletions

File tree

‎packages/services/service-messaging/src/device-registry.integration.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async function thrown(p: Promise<unknown>): Promise<any> {
8484

8585
describe('push device registry: INotificationService device members over sys_user_device', () => {
8686
it('register, then refresh the same deviceId: one row, token and last_seen_at updated', async () => {
87-
const { service, time, alice, rows } = await boot();
87+
const { ql, service, time, alice, rows } = await boot();
8888
const first = await service.registerDevice(alice, { token: 'tok-1', platform: 'ios', deviceId: 'dev-a', name: 'iPhone' });
8989
expect(first).toEqual({ deviceId: 'dev-a', success: true });
9090
const [before] = await rows({ user_id: alice });
@@ -95,6 +95,8 @@ describe('push device registry: INotificationService device members over sys_use
9595

9696
const after = await rows({ user_id: alice });
9797
expect(after).toHaveLength(1);
98+
// Rule (a): the registered schema carries no organization column.
99+
expect(Object.keys((ql.registry.getObject(USER_DEVICE_OBJECT) as any).fields)).not.toContain('organization_id');
98100
expect(after[0].id).toBe(before.id);
99101
expect(after[0].token).toBe('tok-2');
100102
expect(after[0].locale).toBe('zh-CN');
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The push device registry (ADR-0012 §6): the four `INotificationService`
5+
* device members, over `sys_user_device` (`./objects/user-device.object.ts`).
6+
*
7+
* `MessagingService` delegates `registerDevice` / `unregisterDevice` /
8+
* `listDevices` / `invalidateDeviceToken` here; their contract, word for word,
9+
* is the docblocks on `INotificationService` (`@objectstack/spec/contracts`).
10+
*
11+
* ## Whose rows a call may touch
12+
*
13+
* Every call runs under {@link DEVICE_SYSTEM_CONTEXT}: the table is
14+
* system-owned and closed to the data API, so no principal could reach it.
15+
* What keeps a call to the caller's own rows is the `userId` the device route
16+
* takes from the session (`runtime/src/domains/notifications.ts`), and these
17+
* rules:
18+
*
19+
* - a read of the caller's devices is keyed `where: { user_id }`;
20+
* - an update or delete addresses only an id that a read of the SAME call
21+
* returned;
22+
* - the only row of ANOTHER user a call touches is the one holding the token
23+
* the caller registers, which it deletes (spec rule b: the token moves).
24+
*
25+
* ## One holder per token, and how that is enforced (spec rule b)
26+
*
27+
* Three layers, each closing what the one before cannot:
28+
*
29+
* 1. Within this process, registrations of one token are serialised
30+
* ({@link withTokenLock}), so two requests from the same phone cannot
31+
* interleave their read and their write.
32+
* 2. Across processes, `sys_user_device.token` carries a unique index, so a
33+
* second holder can never be stored: a racing writer's insert or update is
34+
* refused by the database.
35+
* 3. That refusal is not the caller's error. The registration runs once more
36+
* ({@link REGISTER_ATTEMPTS}): the re-read now sees the winner's row and
37+
* moves the token from it, which is what the losing request asked for.
38+
*/
39+
40+
import { randomUUID } from 'node:crypto';
41+
import type { IDataEngine } from '@objectstack/spec/contracts';
42+
import type {
43+
RegisterDeviceRequest,
44+
RegisterDeviceResponse,
45+
UnregisterDeviceResponse,
46+
} from '@objectstack/spec/api';
47+
import type { UserDevice } from '@objectstack/spec/system';
48+
import { isUniqueViolationError, validationFailure } from '@objectstack/types';
49+
50+
/** The registry's table. */
51+
export const USER_DEVICE_OBJECT = 'sys_user_device';
52+
53+
/**
54+
* The explicit system opt-in every registry call carries. See the module
55+
* docblock for what bounds it instead of row-level security.
56+
*/
57+
export const DEVICE_SYSTEM_CONTEXT = { isSystem: true } as const;
58+
59+
/**
60+
* How many times a registration runs before a unique-index refusal is let
61+
* through. Two: the first attempt, and the one that re-reads after a racing
62+
* writer won.
63+
*/
64+
const REGISTER_ATTEMPTS = 2;
65+
66+
type Vendor = 'apns' | 'fcm';
67+
68+
/** `platform` → the vendor a registration without `vendor` gets. `web` has none. */
69+
const DEFAULT_VENDOR: Readonly<Record<string, Vendor | undefined>> = {
70+
ios: 'apns',
71+
android: 'fcm',
72+
};
73+
74+
type DeviceRow = Record<string, unknown> & { id?: unknown };
75+
76+
export interface DeviceRegistryOptions {
77+
getData: () => IDataEngine | undefined;
78+
now?: () => string;
79+
}
80+
81+
/**
82+
* The error `unregisterDevice` throws for a device id this user does not hold,
83+
* whether it is unknown or another user's: `code: 'NOT_FOUND'`, `status: 404`,
84+
* which every HTTP door answers as that (`resolveThrownHttpError`). Never a
85+
* distinct "forbidden", so a caller cannot learn that the id exists for
86+
* somebody else.
87+
*/
88+
export function deviceNotFoundError(): Error {
89+
const err = new Error('Device not found.') as Error & { code?: string; status?: number };
90+
err.code = 'NOT_FOUND';
91+
err.status = 404;
92+
return err;
93+
}
94+
95+
export class DeviceRegistry {
96+
private readonly now: () => string;
97+
/** The in-process tail of each token's registration chain. */
98+
private readonly tokenTails = new Map<string, Promise<unknown>>();
99+
100+
constructor(private readonly opts: DeviceRegistryOptions) {
101+
this.now = opts.now ?? (() => new Date().toISOString());
102+
}
103+
104+
private engine(): IDataEngine {
105+
const data = this.opts.getData();
106+
if (!data) throw new Error('[messaging] the device registry has no data engine');
107+
return data;
108+
}
109+
110+
/** `INotificationService.registerDevice`. */
111+
async register(userId: string, request: RegisterDeviceRequest): Promise<RegisterDeviceResponse> {
112+
const vendor = request.vendor ?? DEFAULT_VENDOR[request.platform];
113+
if (!vendor) {
114+
throw validationFailure(
115+
'A web device registration names its vendor (apns or fcm): web has no default vendor.',
116+
[{ field: 'vendor', code: 'required', message: 'Required for a web registration: apns or fcm' }],
117+
);
118+
}
119+
return this.withTokenLock(request.token, async () => {
120+
for (let attempt = 1; ; attempt += 1) {
121+
try {
122+
return await this.registerOnce(userId, request, vendor);
123+
} catch (err) {
124+
if (attempt >= REGISTER_ATTEMPTS || !isUniqueViolationError(err)) throw err;
125+
}
126+
}
127+
});
128+
}
129+
130+
private async registerOnce(
131+
userId: string,
132+
request: RegisterDeviceRequest,
133+
vendor: Vendor,
134+
): Promise<RegisterDeviceResponse> {
135+
const data = this.engine();
136+
const holder = await this.findOne(data, { token: request.token });
137+
138+
// The row this registration refreshes: the named device, else (no
139+
// deviceId) the caller's own row that already holds this token.
140+
let target: DeviceRow | null = null;
141+
if (request.deviceId) {
142+
target = await this.findOne(data, { user_id: userId, device_id: request.deviceId });
143+
} else if (holder && String(holder.user_id) === userId) {
144+
target = holder;
145+
}
146+
147+
// The token moves: whoever else holds it — another user's device, or
148+
// this user's stale row under another deviceId — gives it up, by row.
149+
if (holder && (!target || String(holder.id) !== String(target.id))) {
150+
await data.delete(USER_DEVICE_OBJECT, { where: { id: holder.id }, context: DEVICE_SYSTEM_CONTEXT } as never);
151+
}
152+
153+
const at = this.now();
154+
const values = {
155+
token: request.token,
156+
vendor,
157+
platform: request.platform,
158+
app_id: request.appId ?? null,
159+
locale: request.locale ?? null,
160+
name: request.name ?? null,
161+
enabled: true,
162+
invalidated_at: null,
163+
last_seen_at: at,
164+
};
165+
166+
if (target) {
167+
await data.update(
168+
USER_DEVICE_OBJECT,
169+
values,
170+
{ where: { id: target.id }, context: DEVICE_SYSTEM_CONTEXT } as never,
171+
);
172+
return { deviceId: String(target.device_id), success: true };
173+
}
174+
175+
const deviceId = request.deviceId ?? randomUUID();
176+
await data.insert(
177+
USER_DEVICE_OBJECT,
178+
{ id: randomUUID(), user_id: userId, device_id: deviceId, ...values },
179+
{ context: DEVICE_SYSTEM_CONTEXT },
180+
);
181+
return { deviceId, success: true };
182+
}
183+
184+
/** `INotificationService.unregisterDevice`. */
185+
async unregister(userId: string, deviceId: string): Promise<UnregisterDeviceResponse> {
186+
const data = this.engine();
187+
const own = await this.findOne(data, { user_id: userId, device_id: deviceId });
188+
if (!own) throw deviceNotFoundError();
189+
await data.delete(USER_DEVICE_OBJECT, { where: { id: own.id }, context: DEVICE_SYSTEM_CONTEXT } as never);
190+
return { success: true };
191+
}
192+
193+
/** `INotificationService.listDevices`: newest `lastSeenAt` first, never-seen last. */
194+
async list(userId: string): Promise<UserDevice[]> {
195+
const data = this.engine();
196+
const rows = await data.find(
197+
USER_DEVICE_OBJECT,
198+
{ where: { user_id: userId } },
199+
{ context: DEVICE_SYSTEM_CONTEXT },
200+
) as DeviceRow[];
201+
return rows
202+
.map(toUserDevice)
203+
.sort((a, b) => {
204+
if (a.lastSeenAt === b.lastSeenAt) return 0;
205+
if (!a.lastSeenAt) return 1;
206+
if (!b.lastSeenAt) return -1;
207+
return a.lastSeenAt < b.lastSeenAt ? 1 : -1;
208+
});
209+
}
210+
211+
/** `INotificationService.invalidateDeviceToken`: the row is kept, disabled. */
212+
async invalidate(token: string): Promise<void> {
213+
const data = this.engine();
214+
const holder = await this.findOne(data, { token });
215+
if (!holder) return;
216+
await data.update(
217+
USER_DEVICE_OBJECT,
218+
{ enabled: false, invalidated_at: this.now() },
219+
{ where: { id: holder.id }, context: DEVICE_SYSTEM_CONTEXT } as never,
220+
);
221+
}
222+
223+
private async findOne(data: IDataEngine, where: Record<string, unknown>): Promise<DeviceRow | null> {
224+
const row = await data.findOne(USER_DEVICE_OBJECT, { where }, { context: DEVICE_SYSTEM_CONTEXT });
225+
return (row as DeviceRow | null) ?? null;
226+
}
227+
228+
/**
229+
* Run `work` after every earlier registration of `token` in this process
230+
* has settled, so the read and the write of one registration are never
231+
* interleaved with another's for the same token.
232+
*/
233+
private async withTokenLock<T>(token: string, work: () => Promise<T>): Promise<T> {
234+
const previous = this.tokenTails.get(token) ?? Promise.resolve();
235+
const run = previous.then(work, work);
236+
const tail = run.catch(() => undefined);
237+
this.tokenTails.set(token, tail);
238+
try {
239+
return await run;
240+
} finally {
241+
if (this.tokenTails.get(token) === tail) this.tokenTails.delete(token);
242+
}
243+
}
244+
}
245+
246+
function iso(value: unknown): string | undefined {
247+
if (value === null || value === undefined || value === '') return undefined;
248+
if (value instanceof Date) return value.toISOString();
249+
return new Date(String(value)).toISOString();
250+
}
251+
252+
/** A stored row in spec's `UserDeviceSchema` shape. */
253+
function toUserDevice(row: DeviceRow): UserDevice {
254+
// The engine answers a boolean column as a boolean on every driver.
255+
const enabled = row.enabled === true;
256+
const device: UserDevice = {
257+
deviceId: String(row.device_id),
258+
userId: String(row.user_id),
259+
vendor: row.vendor as Vendor,
260+
token: String(row.token),
261+
platform: row.platform as UserDevice['platform'],
262+
enabled,
263+
};
264+
if (row.app_id) device.appId = String(row.app_id);
265+
if (row.locale) device.locale = String(row.locale);
266+
if (row.name) device.name = String(row.name);
267+
const invalidatedAt = iso(row.invalidated_at);
268+
if (!enabled && invalidatedAt) device.invalidatedAt = invalidatedAt;
269+
const lastSeenAt = iso(row.last_seen_at);
270+
if (lastSeenAt) device.lastSeenAt = lastSeenAt;
271+
return device;
272+
}

‎packages/services/service-messaging/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,8 @@ export {
219219
NotificationTemplate,
220220
HttpDelivery,
221221
SYS_HTTP_DELIVERY,
222+
UserDevice,
222223
} from './objects/index.js';
224+
225+
// Push device registry (ADR-0012 §6) — `INotificationService`'s device members.
226+
export { USER_DEVICE_OBJECT } from './device-registry.js';

‎packages/services/service-messaging/src/messaging-service-plugin.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
NotificationPreference,
3333
NotificationTemplate,
3434
HttpDelivery,
35+
UserDevice,
3536
} from './objects/index.js';
3637
// The L2 event this service writes on every `emit()`. It is OWNED by
3738
// platform-objects (it is in `PLATFORM_OBJECTS_BY_PACKAGE`), and — like
@@ -84,7 +85,7 @@ function lostWithoutStorage(objectName: string): string {
8485
* durability degradation: ① the consequence, and that the deployment keeps
8586
* looking healthy; ② the fix. Both remedies are measured: this provisioning
8687
* runs on every boot that runs DDL, and `os migrate apply` creates all eight
87-
* of these tables.
88+
* of these tables (measured before `sys_user_device` joined them as the ninth).
8889
*/
8990
function provisionFailureLine(objectName: string): string {
9091
return (
@@ -352,6 +353,9 @@ export class MessagingServicePlugin implements Plugin {
352353
NotificationPreference,
353354
NotificationTemplate,
354355
HttpDelivery,
356+
// The push device registry (ADR-0012 §6): the
357+
// `notification` slot's device members write it.
358+
UserDevice,
355359
],
356360
navigationContributions: [
357361
{
@@ -609,7 +613,7 @@ export class MessagingServicePlugin implements Plugin {
609613
* cold-start budget) and says once, at `info`, what it did not provision and
610614
* why. Not `warn`: nothing is degraded, because the operator declared this.
611615
* `os migrate apply`, the documented path, was measured to create all eight
612-
* tables. `syncObjectSchema` itself stays ungated, because it is also how a
616+
* tables (before `sys_user_device` joined them as the ninth). `syncObjectSchema` itself stays ungated, because it is also how a
613617
* runtime publish gives a new object its table.
614618
*/
615619
private async provisionSystemTables(engine: IDataEngine, ctx: PluginContext): Promise<void> {
@@ -626,6 +630,7 @@ export class MessagingServicePlugin implements Plugin {
626630
NotificationPreference,
627631
NotificationTemplate,
628632
HttpDelivery,
633+
UserDevice,
629634
];
630635
// [#22984] FIRST: see the docblock. One line, naming every skipped object.
631636
if (bootSchemaSyncSkipped(engine)) {

0 commit comments

Comments
 (0)