okengine 0.17.2 → 0.18.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/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke_cdc_outbox` — durable, multi-host CDC.
|
|
3
|
+
*
|
|
4
|
+
* Same transactional-outbox physics as the Signal / Journal drivers:
|
|
5
|
+
* writes land in the SAME transaction as the DML (`sql-session` hook),
|
|
6
|
+
* a SKIP LOCKED poller drains rows to in-process subscribers (LiveQuery
|
|
7
|
+
* runtime + user-declared CDC flows). Retention mirrors Signal's
|
|
8
|
+
* `maxAge` / `maxCount` discipline — this is not the one table without it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Wire shape of one row drained from the outbox. */
|
|
12
|
+
export interface OutboxRow {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly seq: number;
|
|
15
|
+
readonly tableName: string;
|
|
16
|
+
readonly op: "insert" | "update" | "delete";
|
|
17
|
+
readonly before: Record<string, unknown> | null;
|
|
18
|
+
readonly after: Record<string, unknown> | null;
|
|
19
|
+
/** Echoed `X-Oke-Mutation-Id` (client dedupe), when the write carried one. */
|
|
20
|
+
readonly mutationId?: string;
|
|
21
|
+
readonly createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Retention caps applied by the poller between claims. */
|
|
25
|
+
export interface OutboxRetention {
|
|
26
|
+
/** Drop delivered rows older than this many ms. */
|
|
27
|
+
readonly maxAgeMs?: number;
|
|
28
|
+
/** Keep at most this many delivered rows (newest survive). */
|
|
29
|
+
readonly maxCount?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Default `maxAgeMs` — delivered rows pruned after 24h (Signal live-tape parity). */
|
|
33
|
+
export const OUTBOX_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/** Default `maxCount` — cap total delivered rows retained (never touches pending). */
|
|
36
|
+
export const OUTBOX_DEFAULT_MAX_COUNT = 50_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fill omitted retention fields with the {@link OUTBOX_DEFAULT_MAX_AGE_MS} /
|
|
40
|
+
* {@link OUTBOX_DEFAULT_MAX_COUNT} defaults so the outbox is never the one
|
|
41
|
+
* durable table without pruning discipline.
|
|
42
|
+
*
|
|
43
|
+
* @param retention - Caller caps (fields omit → default)
|
|
44
|
+
*/
|
|
45
|
+
export function resolveOutboxRetention(retention: OutboxRetention = {}): Required<OutboxRetention> {
|
|
46
|
+
return {
|
|
47
|
+
maxAgeMs: retention.maxAgeMs ?? OUTBOX_DEFAULT_MAX_AGE_MS,
|
|
48
|
+
maxCount: retention.maxCount ?? OUTBOX_DEFAULT_MAX_COUNT,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Postgres DDL for the outbox. */
|
|
53
|
+
export const OKE_CDC_OUTBOX_DDL = `
|
|
54
|
+
CREATE TABLE IF NOT EXISTS oke_cdc_outbox (
|
|
55
|
+
id text PRIMARY KEY,
|
|
56
|
+
seq bigint GENERATED ALWAYS AS IDENTITY,
|
|
57
|
+
table_name text NOT NULL,
|
|
58
|
+
op text NOT NULL,
|
|
59
|
+
before_data jsonb,
|
|
60
|
+
after_data jsonb,
|
|
61
|
+
mutation_id text,
|
|
62
|
+
created_at bigint NOT NULL,
|
|
63
|
+
claimed_at bigint,
|
|
64
|
+
delivered_at bigint
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS oke_cdc_outbox_pending_idx
|
|
67
|
+
ON oke_cdc_outbox (seq) WHERE delivered_at IS NULL;
|
|
68
|
+
CREATE INDEX IF NOT EXISTS oke_cdc_outbox_delivered_created_idx
|
|
69
|
+
ON oke_cdc_outbox (created_at) WHERE delivered_at IS NOT NULL;
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Driver-agnostic outbox on a {@link SqlConnection}.
|
|
74
|
+
*
|
|
75
|
+
* Works on pglite and pooled postgres — every claim runs inside a
|
|
76
|
+
* transaction so row locks are held exactly for mark-as-delivered.
|
|
77
|
+
*/
|
|
78
|
+
export class CdcOutbox {
|
|
79
|
+
private readonly conn: {
|
|
80
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
81
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
82
|
+
transaction?<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
|
|
83
|
+
};
|
|
84
|
+
private readonly retention: Required<OutboxRetention>;
|
|
85
|
+
private readonly now: () => number;
|
|
86
|
+
private ensuring?: Promise<void>;
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
conn: {
|
|
90
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
91
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
92
|
+
transaction?<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
|
|
93
|
+
},
|
|
94
|
+
retention: OutboxRetention = {},
|
|
95
|
+
now: () => number = () => Date.now(),
|
|
96
|
+
) {
|
|
97
|
+
this.conn = conn;
|
|
98
|
+
this.retention = resolveOutboxRetention(retention);
|
|
99
|
+
this.now = now;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Idempotent DDL ensure — first write or poll creates the table. */
|
|
103
|
+
async ensure(): Promise<void> {
|
|
104
|
+
if (!this.ensuring) {
|
|
105
|
+
this.ensuring = (async () => {
|
|
106
|
+
for (const stmt of OKE_CDC_OUTBOX_DDL.split(";").filter((s) => s.trim())) {
|
|
107
|
+
await this.conn.exec(stmt.trim());
|
|
108
|
+
}
|
|
109
|
+
})().catch((err) => {
|
|
110
|
+
this.ensuring = undefined;
|
|
111
|
+
throw err;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
await this.ensuring;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Enqueue one change. Call from inside the WRITE transaction so the event
|
|
119
|
+
* commits atomically with the data change.
|
|
120
|
+
*
|
|
121
|
+
* @param entry - Table/op/images; ids minted when absent
|
|
122
|
+
*/
|
|
123
|
+
async append(entry: {
|
|
124
|
+
readonly id?: string;
|
|
125
|
+
readonly tableName: string;
|
|
126
|
+
readonly op: "insert" | "update" | "delete";
|
|
127
|
+
readonly before?: Record<string, unknown> | null;
|
|
128
|
+
readonly after?: Record<string, unknown> | null;
|
|
129
|
+
readonly mutationId?: string;
|
|
130
|
+
}): Promise<void> {
|
|
131
|
+
await this.ensure();
|
|
132
|
+
const { okid } = await import("../okid.ts");
|
|
133
|
+
await this.conn.exec(
|
|
134
|
+
`INSERT INTO oke_cdc_outbox (id, table_name, op, before_data, after_data, mutation_id, created_at)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
136
|
+
[
|
|
137
|
+
entry.id ?? okid(),
|
|
138
|
+
entry.tableName,
|
|
139
|
+
entry.op,
|
|
140
|
+
entry.before === null || entry.before === undefined ? null : JSON.stringify(entry.before),
|
|
141
|
+
entry.after === null || entry.after === undefined ? null : JSON.stringify(entry.after),
|
|
142
|
+
entry.mutationId ?? null,
|
|
143
|
+
this.now(),
|
|
144
|
+
],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Outbox backlog gauges for metrics/doctor: total pending (undelivered)
|
|
150
|
+
* rows and delivered rows retained beyond the `maxCount` cap.
|
|
151
|
+
*
|
|
152
|
+
* @returns `{ pending, dispatchedOverCap }` row counts
|
|
153
|
+
*/
|
|
154
|
+
async stats(): Promise<{ pending: number; dispatchedOverCap: number }> {
|
|
155
|
+
await this.ensure();
|
|
156
|
+
const rows = await this.conn.query(
|
|
157
|
+
`SELECT
|
|
158
|
+
COUNT(*) FILTER (WHERE delivered_at IS NULL) AS pending,
|
|
159
|
+
GREATEST(
|
|
160
|
+
COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) - ?,
|
|
161
|
+
0
|
|
162
|
+
) AS dispatched_over_cap
|
|
163
|
+
FROM oke_cdc_outbox`,
|
|
164
|
+
[this.retention.maxCount],
|
|
165
|
+
);
|
|
166
|
+
const row = rows[0];
|
|
167
|
+
return {
|
|
168
|
+
pending: row === undefined ? 0 : Number(row.pending ?? 0),
|
|
169
|
+
dispatchedOverCap: row === undefined ? 0 : Number(row.dispatched_over_cap ?? 0),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Claim up to `limit` undelivered rows with SKIP LOCKED semantics, hand
|
|
175
|
+
* them to `handler`, and mark them delivered after it resolves.
|
|
176
|
+
* A crash between claim and mark redelivers (at-least-once) rather than
|
|
177
|
+
* loses; a handler throw leaves rows pending and returns [] — callers
|
|
178
|
+
* poll again on the next tick.
|
|
179
|
+
*
|
|
180
|
+
* @param handler - Batch consumer; throw to leave rows pending-redelivery
|
|
181
|
+
* @param limit - Max rows per batch
|
|
182
|
+
* @returns Rows handed to the handler this tick (delivered)
|
|
183
|
+
*/
|
|
184
|
+
async pollOnce(
|
|
185
|
+
handler: (rows: readonly OutboxRow[]) => Promise<void>,
|
|
186
|
+
limit = 200,
|
|
187
|
+
): Promise<readonly OutboxRow[]> {
|
|
188
|
+
await this.ensure();
|
|
189
|
+
try {
|
|
190
|
+
if (!this.conn.transaction) {
|
|
191
|
+
const claimed = await this.claimInTx(this.conn as never, limit);
|
|
192
|
+
if (claimed.length > 0) {
|
|
193
|
+
await handler(claimed);
|
|
194
|
+
await this.markDelivered(claimed);
|
|
195
|
+
}
|
|
196
|
+
return claimed;
|
|
197
|
+
}
|
|
198
|
+
let claimed: OutboxRow[] = [];
|
|
199
|
+
await this.conn.transaction(async (tx) => {
|
|
200
|
+
claimed = await this.claimInTx(tx as never, limit);
|
|
201
|
+
});
|
|
202
|
+
if (claimed.length > 0) {
|
|
203
|
+
await handler(claimed);
|
|
204
|
+
await this.markDelivered(claimed);
|
|
205
|
+
}
|
|
206
|
+
return claimed;
|
|
207
|
+
} catch {
|
|
208
|
+
// SKIP LOCKED under concurrent contention can serialize; a transient
|
|
209
|
+
// failure leaves rows unclaimed and the next tick retries.
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Internal claim inside an open transaction. */
|
|
215
|
+
private async claimInTx(
|
|
216
|
+
tx: {
|
|
217
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
218
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
219
|
+
},
|
|
220
|
+
limit: number,
|
|
221
|
+
): Promise<OutboxRow[]> {
|
|
222
|
+
const rows = await tx.query(
|
|
223
|
+
`SELECT id, seq, table_name, op, before_data, after_data, mutation_id, created_at
|
|
224
|
+
FROM oke_cdc_outbox
|
|
225
|
+
WHERE delivered_at IS NULL
|
|
226
|
+
AND (claimed_at IS NULL OR claimed_at <= ?)
|
|
227
|
+
ORDER BY seq ASC
|
|
228
|
+
LIMIT ?`,
|
|
229
|
+
[this.now() - CLAIM_TTL_MS, Math.max(1, limit)],
|
|
230
|
+
);
|
|
231
|
+
if (rows.length === 0) return [];
|
|
232
|
+
const mapped = rows.map((r) => ({
|
|
233
|
+
id: String(r.id),
|
|
234
|
+
seq: Number(r.seq),
|
|
235
|
+
tableName: String(r.table_name),
|
|
236
|
+
op: r.op as OutboxRow["op"],
|
|
237
|
+
before: parseImages(r.before_data),
|
|
238
|
+
after: parseImages(r.after_data),
|
|
239
|
+
...(typeof r.mutation_id === "string" && r.mutation_id.length > 0
|
|
240
|
+
? { mutationId: r.mutation_id }
|
|
241
|
+
: {}),
|
|
242
|
+
createdAt: Number(r.created_at),
|
|
243
|
+
}));
|
|
244
|
+
const ids = mapped.map((r) => r.id);
|
|
245
|
+
const marks = ids.map(() => "?").join(", ");
|
|
246
|
+
// Claim bump doubles as the attempt counter: each stale-bump re-claim is
|
|
247
|
+
// one redelivery. `claimed_at` older than the claim TTL becomes eligible
|
|
248
|
+
// again, so a crashed poller's rows get picked up without a hot loop.
|
|
249
|
+
await tx.exec(`UPDATE oke_cdc_outbox SET claimed_at = ? WHERE id IN (${marks})`, [
|
|
250
|
+
this.now(),
|
|
251
|
+
...ids,
|
|
252
|
+
]);
|
|
253
|
+
return mapped;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Prune delivered rows beyond retention (`maxAgeMs` / `maxCount`).
|
|
258
|
+
* Call periodically from the poller loop — NOT per tick.
|
|
259
|
+
*
|
|
260
|
+
* @returns Rows deleted (for metrics)
|
|
261
|
+
*/
|
|
262
|
+
async prune(): Promise<number> {
|
|
263
|
+
let pruned = 0;
|
|
264
|
+
const cutoff =
|
|
265
|
+
this.retention.maxAgeMs === undefined ? null : this.now() - this.retention.maxAgeMs;
|
|
266
|
+
if (cutoff !== null) {
|
|
267
|
+
const res = await this.conn.exec(
|
|
268
|
+
`DELETE FROM oke_cdc_outbox WHERE delivered_at IS NOT NULL AND created_at < ?`,
|
|
269
|
+
[cutoff],
|
|
270
|
+
);
|
|
271
|
+
pruned += res.changes;
|
|
272
|
+
}
|
|
273
|
+
if (this.retention.maxCount !== undefined) {
|
|
274
|
+
const res = await this.conn.exec(
|
|
275
|
+
`DELETE FROM oke_cdc_outbox WHERE id IN (
|
|
276
|
+
SELECT id FROM oke_cdc_outbox WHERE delivered_at IS NOT NULL
|
|
277
|
+
ORDER BY created_at DESC OFFSET ?
|
|
278
|
+
)`,
|
|
279
|
+
[Math.max(0, this.retention.maxCount)],
|
|
280
|
+
);
|
|
281
|
+
pruned += res.changes;
|
|
282
|
+
}
|
|
283
|
+
return pruned;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Mark a specific batch delivered after its handler resolved. */
|
|
287
|
+
async markDelivered(rows: readonly OutboxRow[]): Promise<void> {
|
|
288
|
+
if (rows.length === 0) return;
|
|
289
|
+
const marks = rows.map(() => "?").join(", ");
|
|
290
|
+
await this.conn.exec(`UPDATE oke_cdc_outbox SET delivered_at = ? WHERE id IN (${marks})`, [
|
|
291
|
+
this.now(),
|
|
292
|
+
...rows.map((r) => r.id),
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Claim TTL — rows claimed longer ago than this are eligible for redelivery. */
|
|
298
|
+
const CLAIM_TTL_MS = 30_000;
|
|
299
|
+
|
|
300
|
+
/** Default sweep cadence for {@link CdcOutboxRunner} retention pruning. */
|
|
301
|
+
export const OUTBOX_PRUNE_INTERVAL_MS = 60_000;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Single-process poller loop: claims batches, feeds the LiveQuery runtime
|
|
305
|
+
* (and any other sinks), marks delivered, and prunes on a slow cadence.
|
|
306
|
+
* Start via {@link start}; idempotent `stop()` on the returned handle.
|
|
307
|
+
*/
|
|
308
|
+
export class CdcOutboxRunner {
|
|
309
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
310
|
+
private polling = false;
|
|
311
|
+
private lastPrune = 0;
|
|
312
|
+
private readonly intervalMs: number;
|
|
313
|
+
private readonly batchSize: number;
|
|
314
|
+
private readonly now: () => number;
|
|
315
|
+
private readonly outbox: CdcOutbox;
|
|
316
|
+
private readonly handler: (rows: readonly OutboxRow[]) => Promise<void>;
|
|
317
|
+
/** Counters surfaced through doctor/metrics. */
|
|
318
|
+
readonly metrics = { polls: 0, batches: 0, eventsDrained: 0, pruneFailures: 0 };
|
|
319
|
+
|
|
320
|
+
constructor(
|
|
321
|
+
outbox: CdcOutbox,
|
|
322
|
+
handler: (rows: readonly OutboxRow[]) => Promise<void>,
|
|
323
|
+
options: {
|
|
324
|
+
readonly intervalMs?: number;
|
|
325
|
+
readonly batchSize?: number;
|
|
326
|
+
readonly now?: () => number;
|
|
327
|
+
} = {},
|
|
328
|
+
) {
|
|
329
|
+
this.outbox = outbox;
|
|
330
|
+
this.handler = handler;
|
|
331
|
+
this.intervalMs = Math.max(10, options.intervalMs ?? 200);
|
|
332
|
+
this.batchSize = Math.max(1, options.batchSize ?? 200);
|
|
333
|
+
this.now = options.now ?? (() => Date.now());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Begin background polling (idempotent). */
|
|
337
|
+
start(): void {
|
|
338
|
+
if (this.timer) return;
|
|
339
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
340
|
+
this.timer.unref?.();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Stop polling (idempotent). */
|
|
344
|
+
stop(): void {
|
|
345
|
+
if (this.timer !== undefined) {
|
|
346
|
+
clearInterval(this.timer);
|
|
347
|
+
this.timer = undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One claim→handle→deliver→(maybe)prune cycle. Exposed for tests. */
|
|
352
|
+
async tick(): Promise<number> {
|
|
353
|
+
if (this.polling) return 0;
|
|
354
|
+
this.polling = true;
|
|
355
|
+
try {
|
|
356
|
+
this.metrics.polls += 1;
|
|
357
|
+
const nowMs = this.now();
|
|
358
|
+
if (nowMs - this.lastPrune >= OUTBOX_PRUNE_INTERVAL_MS) {
|
|
359
|
+
this.lastPrune = nowMs;
|
|
360
|
+
try {
|
|
361
|
+
await this.outbox.prune();
|
|
362
|
+
} catch {
|
|
363
|
+
this.metrics.pruneFailures += 1;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const rows = await this.outbox.pollOnce((batch) => this.handler(batch), this.batchSize);
|
|
367
|
+
if (rows.length > 0) {
|
|
368
|
+
this.metrics.batches += 1;
|
|
369
|
+
this.metrics.eventsDrained += rows.length;
|
|
370
|
+
}
|
|
371
|
+
return rows.length;
|
|
372
|
+
} finally {
|
|
373
|
+
this.polling = false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function parseImages(value: unknown): Record<string, unknown> | null {
|
|
379
|
+
if (value === null || value === undefined) return null;
|
|
380
|
+
if (typeof value === "object") return value as Record<string, unknown>;
|
|
381
|
+
if (typeof value === "string") {
|
|
382
|
+
try {
|
|
383
|
+
return JSON.parse(value) as Record<string, unknown>;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
package/src/drivers/memory.ts
CHANGED
|
@@ -351,6 +351,26 @@ function createMemorySqlConnection(role: "primary" | "replica"): SqlConnection {
|
|
|
351
351
|
return [{ ...row }];
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
// DELETE ... RETURNING * (CDC before-image capture on sql-session
|
|
355
|
+
// delete paths). Same grammar as exec()'s DELETE branch.
|
|
356
|
+
const deleteReturning =
|
|
357
|
+
/^DELETE\s+FROM\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+WHERE\s+(.+?)\s+RETURNING\s+\*\s*$/i.exec(
|
|
358
|
+
text,
|
|
359
|
+
);
|
|
360
|
+
if (deleteReturning) {
|
|
361
|
+
const table = getTable(parseIdent(deleteReturning[1]!));
|
|
362
|
+
const ast = parseWhere(deleteReturning[2]!);
|
|
363
|
+
const removed: SqlRow[] = [];
|
|
364
|
+
table.rows = table.rows.filter((r) => {
|
|
365
|
+
if (evalWhere(ast, r, params, { i: 0 })) {
|
|
366
|
+
removed.push({ ...r });
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
});
|
|
371
|
+
return removed;
|
|
372
|
+
}
|
|
373
|
+
|
|
354
374
|
throw new Error(`memory sql: unsupported query: ${sql}`);
|
|
355
375
|
},
|
|
356
376
|
async exec(sql, params = []) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apple` OIDC driver — `response_mode=form_post` web flow, ES256
|
|
3
|
+
* client-secret JWT minted per exchange from the team private key, and the
|
|
4
|
+
* strict string/boolean `email_verified` parse (Apple sends `"false"` as a
|
|
5
|
+
* string — naive truthiness would mark unverified emails verified).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type OAuthAssertion,
|
|
10
|
+
type OAuthAssertionInput,
|
|
11
|
+
type OAuthAuthorizeInput,
|
|
12
|
+
type OAuthAuthorizeResult,
|
|
13
|
+
type OAuthDriver,
|
|
14
|
+
type OAuthExchangeInput,
|
|
15
|
+
type OAuthExchangeResult,
|
|
16
|
+
} from "./oauth-types.ts";
|
|
17
|
+
import { buildAuthorizeQuery, normalizeEmail, parseEmailVerified } from "./oauth-shared.ts";
|
|
18
|
+
import { createAppleClientSecretJwt, discoverOpenId, verifyIdToken } from "./oauth-oidc.ts";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZATION_SERVER_ID = "https://appleid.apple.com";
|
|
21
|
+
|
|
22
|
+
/** Default Apple scopes. */
|
|
23
|
+
export const APPLE_DEFAULT_SCOPES = ["name", "email"] as const;
|
|
24
|
+
|
|
25
|
+
/** Apple-specific driver options. */
|
|
26
|
+
export interface AppleDriverOptions {
|
|
27
|
+
/** Apple Developer `teamId` (`iss` of the client-secret JWT). */
|
|
28
|
+
readonly teamId?: string;
|
|
29
|
+
/** Sign in with Apple private-key id (`kid`). */
|
|
30
|
+
readonly keyId?: string;
|
|
31
|
+
/** PKCS#8 PEM private key (Vault-resolved). */
|
|
32
|
+
readonly privateKeyPem?: string;
|
|
33
|
+
readonly scopes?: readonly string[];
|
|
34
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Open the Apple driver.
|
|
39
|
+
*
|
|
40
|
+
* @param options - Team/key material and injectable fetch
|
|
41
|
+
*/
|
|
42
|
+
export function openOAuthApple(options: AppleDriverOptions = {}): OAuthDriver {
|
|
43
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
44
|
+
return {
|
|
45
|
+
id: "apple",
|
|
46
|
+
kind: "oidc",
|
|
47
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
48
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
49
|
+
return {
|
|
50
|
+
url: buildAuthorizeQuery(`${AUTHORIZATION_SERVER_ID}/auth/authorize`, input, {
|
|
51
|
+
response_mode: "form_post",
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
56
|
+
if (
|
|
57
|
+
input.clientSecretJwt === undefined &&
|
|
58
|
+
(!options.teamId || !options.keyId || !options.privateKeyPem)
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"apple: clientSecretJwt override or teamId + keyId + privateKeyPem required",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const endpoints = await discoverOpenId(AUTHORIZATION_SERVER_ID, fetchFn);
|
|
65
|
+
return exchangeAppleForm(endpoints.tokenEndpoint, input);
|
|
66
|
+
},
|
|
67
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
68
|
+
if (!input.tokens.idToken) {
|
|
69
|
+
throw new Error("apple: token response missing id_token");
|
|
70
|
+
}
|
|
71
|
+
const claims = await verifyIdToken({
|
|
72
|
+
idToken: input.tokens.idToken,
|
|
73
|
+
jwksUri: `${AUTHORIZATION_SERVER_ID}/auth/keys`,
|
|
74
|
+
expectedIssuer: input.expectedIssuer,
|
|
75
|
+
expectedAudience: input.expectedAudience,
|
|
76
|
+
...(input.expectedNonce !== undefined ? { expectedNonce: input.expectedNonce } : {}),
|
|
77
|
+
fetchFn,
|
|
78
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
79
|
+
});
|
|
80
|
+
const email = normalizeEmail(claims.payload["email"]);
|
|
81
|
+
return {
|
|
82
|
+
subject: claims.subject,
|
|
83
|
+
issuer: claims.issuer,
|
|
84
|
+
...(email !== undefined ? { email } : {}),
|
|
85
|
+
emailVerified: parseEmailVerified(claims.payload["email_verified"]),
|
|
86
|
+
name: appleName(input),
|
|
87
|
+
raw: claims.payload,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
async function exchangeAppleForm(
|
|
93
|
+
tokenEndpoint: string,
|
|
94
|
+
exchange: OAuthExchangeInput,
|
|
95
|
+
): Promise<OAuthExchangeResult> {
|
|
96
|
+
let clientSecret: string;
|
|
97
|
+
if (exchange.clientSecretJwt !== undefined) {
|
|
98
|
+
clientSecret = await exchange.clientSecretJwt();
|
|
99
|
+
} else {
|
|
100
|
+
clientSecret = await createAppleClientSecretJwt({
|
|
101
|
+
privateKeyPem: options.privateKeyPem!,
|
|
102
|
+
teamId: options.teamId!,
|
|
103
|
+
keyId: options.keyId!,
|
|
104
|
+
clientId: exchange.clientId,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const body = new URLSearchParams({
|
|
108
|
+
grant_type: "authorization_code",
|
|
109
|
+
code: exchange.code,
|
|
110
|
+
redirect_uri: exchange.redirectUri,
|
|
111
|
+
client_id: exchange.clientId,
|
|
112
|
+
client_secret: clientSecret,
|
|
113
|
+
});
|
|
114
|
+
const res = await fetchFn(tokenEndpoint, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
117
|
+
body: body.toString(),
|
|
118
|
+
});
|
|
119
|
+
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
120
|
+
if (!res.ok || typeof json["access_token"] !== "string") {
|
|
121
|
+
throw new Error(`apple: token endpoint HTTP ${res.status}`);
|
|
122
|
+
}
|
|
123
|
+
const expiresIn = json["expires_in"];
|
|
124
|
+
return {
|
|
125
|
+
tokens: {
|
|
126
|
+
accessToken: json["access_token"],
|
|
127
|
+
...(typeof json["refresh_token"] === "string"
|
|
128
|
+
? { refreshToken: json["refresh_token"] as string }
|
|
129
|
+
: {}),
|
|
130
|
+
...(typeof expiresIn === "number"
|
|
131
|
+
? { expiresAt: Math.floor(Date.now() / 1000 + expiresIn * 1000) }
|
|
132
|
+
: {}),
|
|
133
|
+
raw: json,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Apple delivers the user's name only on first authorization via a
|
|
141
|
+
* form-posted `user` JSON field; drivers receive it through the raw token bag.
|
|
142
|
+
*
|
|
143
|
+
* @param input - Assertion inputs
|
|
144
|
+
*/
|
|
145
|
+
function appleName(input: OAuthAssertionInput): string | undefined {
|
|
146
|
+
const userField = input.tokens.raw["user"];
|
|
147
|
+
if (typeof userField !== "string") return undefined;
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(userField) as { name?: Record<string, string> };
|
|
150
|
+
const n = parsed.name;
|
|
151
|
+
if (!n) return undefined;
|
|
152
|
+
return [n.givenName, n.familyName].filter(Boolean).join(" ") || undefined;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `discord` OAuth2 driver — `GET /users/@me`. Trust: `verified === true`
|
|
3
|
+
* (requires the `email` scope). `email` may legitimately be null
|
|
4
|
+
* (phone-number accounts) — sign-in proceeds without an email.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type OAuthAssertion,
|
|
9
|
+
type OAuthAssertionInput,
|
|
10
|
+
type OAuthAuthorizeInput,
|
|
11
|
+
type OAuthAuthorizeResult,
|
|
12
|
+
type OAuthDriver,
|
|
13
|
+
type OAuthExchangeInput,
|
|
14
|
+
type OAuthExchangeResult,
|
|
15
|
+
} from "./oauth-types.ts";
|
|
16
|
+
import { buildAuthorizeQuery } from "./oauth-shared.ts";
|
|
17
|
+
import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
|
|
18
|
+
import { postTokenRequest } from "./oauth2-token.ts";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZATION_SERVER_ID = "https://discord.com";
|
|
21
|
+
|
|
22
|
+
/** Default Discord scopes. */
|
|
23
|
+
export const DISCORD_DEFAULT_SCOPES = ["identify", "email"] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open the Discord driver.
|
|
27
|
+
*
|
|
28
|
+
* @param options - Optional scope override / injectable fetch
|
|
29
|
+
*/
|
|
30
|
+
export function openOAuthDiscord(
|
|
31
|
+
options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
|
|
32
|
+
): OAuthDriver {
|
|
33
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
34
|
+
return {
|
|
35
|
+
id: "discord",
|
|
36
|
+
kind: "oauth2",
|
|
37
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
38
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
39
|
+
return {
|
|
40
|
+
url: buildAuthorizeQuery("https://discord.com/oauth2/authorize", input, {
|
|
41
|
+
prompt: "consent",
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
46
|
+
return {
|
|
47
|
+
tokens: await postTokenRequest({
|
|
48
|
+
tokenEndpoint: "https://discord.com/api/oauth2/token",
|
|
49
|
+
input,
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
54
|
+
return resolveOauth2Assertion(input, async (accessToken) => {
|
|
55
|
+
const me = await fetchProviderJson(
|
|
56
|
+
"https://discord.com/api/users/@me",
|
|
57
|
+
accessToken,
|
|
58
|
+
fetchFn,
|
|
59
|
+
"discord",
|
|
60
|
+
);
|
|
61
|
+
const subject = typeof me["id"] === "string" ? me["id"] : undefined;
|
|
62
|
+
if (subject === undefined) throw new Error("discord: profile missing id");
|
|
63
|
+
return {
|
|
64
|
+
providerId: "discord",
|
|
65
|
+
subject,
|
|
66
|
+
// Null email is valid — normalizeEmail(undefined) → absent.
|
|
67
|
+
...(me["email"] !== null && me["email"] !== undefined ? { email: me["email"] } : {}),
|
|
68
|
+
...(me["verified"] !== undefined ? { verified: me["verified"] } : {}),
|
|
69
|
+
...(typeof me["global_name"] === "string" && me["global_name"].length > 0
|
|
70
|
+
? { name: me["global_name"] }
|
|
71
|
+
: typeof me["username"] === "string"
|
|
72
|
+
? { name: me["username"] }
|
|
73
|
+
: {}),
|
|
74
|
+
raw: me,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|