easy-ping 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.
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/adapter-f0OR2DhY.d.cts +200 -0
- package/dist/adapter-f0OR2DhY.d.ts +200 -0
- package/dist/adapters/drizzle.cjs +440 -0
- package/dist/adapters/drizzle.cjs.map +1 -0
- package/dist/adapters/drizzle.d.cts +991 -0
- package/dist/adapters/drizzle.d.ts +991 -0
- package/dist/adapters/drizzle.js +409 -0
- package/dist/adapters/drizzle.js.map +1 -0
- package/dist/adapters/mongodb.cjs +458 -0
- package/dist/adapters/mongodb.cjs.map +1 -0
- package/dist/adapters/mongodb.d.cts +32 -0
- package/dist/adapters/mongodb.d.ts +32 -0
- package/dist/adapters/mongodb.js +376 -0
- package/dist/adapters/mongodb.js.map +1 -0
- package/dist/browser.cjs +96 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +45 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.js +89 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-BV3U542P.js +11 -0
- package/dist/chunk-BV3U542P.js.map +1 -0
- package/dist/chunk-NYDVNMKG.js +62 -0
- package/dist/chunk-NYDVNMKG.js.map +1 -0
- package/dist/chunk-OZSOIX23.js +120 -0
- package/dist/chunk-OZSOIX23.js.map +1 -0
- package/dist/chunk-PTGHMCCG.js +23 -0
- package/dist/chunk-PTGHMCCG.js.map +1 -0
- package/dist/chunk-W3BMEPPQ.js +50 -0
- package/dist/chunk-W3BMEPPQ.js.map +1 -0
- package/dist/client.cjs +160 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +42 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +723 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +49 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +8 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.js +46 -0
- package/dist/node.js.map +1 -0
- package/dist/plugin-Bqj9xAOS.d.cts +217 -0
- package/dist/plugin-bTJKmnlq.d.ts +217 -0
- package/dist/plugins/digests.cjs +206 -0
- package/dist/plugins/digests.cjs.map +1 -0
- package/dist/plugins/digests.d.cts +68 -0
- package/dist/plugins/digests.d.ts +68 -0
- package/dist/plugins/digests.js +201 -0
- package/dist/plugins/digests.js.map +1 -0
- package/dist/plugins/preferences-client.cjs +23 -0
- package/dist/plugins/preferences-client.cjs.map +1 -0
- package/dist/plugins/preferences-client.d.cts +29 -0
- package/dist/plugins/preferences-client.d.ts +29 -0
- package/dist/plugins/preferences-client.js +18 -0
- package/dist/plugins/preferences-client.js.map +1 -0
- package/dist/plugins/preferences-react.cjs +125 -0
- package/dist/plugins/preferences-react.cjs.map +1 -0
- package/dist/plugins/preferences-react.d.cts +40 -0
- package/dist/plugins/preferences-react.d.ts +40 -0
- package/dist/plugins/preferences-react.js +101 -0
- package/dist/plugins/preferences-react.js.map +1 -0
- package/dist/plugins/preferences.cjs +147 -0
- package/dist/plugins/preferences.cjs.map +1 -0
- package/dist/plugins/preferences.d.cts +22 -0
- package/dist/plugins/preferences.d.ts +22 -0
- package/dist/plugins/preferences.js +119 -0
- package/dist/plugins/preferences.js.map +1 -0
- package/dist/plugins/push.cjs +146 -0
- package/dist/plugins/push.cjs.map +1 -0
- package/dist/plugins/push.d.cts +60 -0
- package/dist/plugins/push.d.ts +60 -0
- package/dist/plugins/push.js +144 -0
- package/dist/plugins/push.js.map +1 -0
- package/dist/provider-CA45WVqt.d.cts +23 -0
- package/dist/provider-CA45WVqt.d.ts +23 -0
- package/dist/providers/resend.cjs +69 -0
- package/dist/providers/resend.cjs.map +1 -0
- package/dist/providers/resend.d.cts +20 -0
- package/dist/providers/resend.d.ts +20 -0
- package/dist/providers/resend.js +66 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/providers/web-push.cjs +211 -0
- package/dist/providers/web-push.cjs.map +1 -0
- package/dist/providers/web-push.d.cts +33 -0
- package/dist/providers/web-push.d.ts +33 -0
- package/dist/providers/web-push.js +193 -0
- package/dist/providers/web-push.js.map +1 -0
- package/dist/react.cjs +218 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +57 -0
- package/dist/react.d.ts +57 -0
- package/dist/react.js +193 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.cjs +263 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +180 -0
- package/dist/schema.d.ts +180 -0
- package/dist/schema.js +200 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing.cjs +342 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +38 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +340 -0
- package/dist/testing.js.map +1 -0
- package/package.json +168 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
type Channel = "inApp" | "email" | "push" | "sms" | "slack";
|
|
2
|
+
type DeliveryStatus = "pending" | "claimed" | "sent" | "failed" | "skipped";
|
|
3
|
+
type DeliveryMode = "inline" | "deferred" | "worker" | "cron";
|
|
4
|
+
type Frequency = "instant" | "daily" | "weekly" | "off";
|
|
5
|
+
type SkipReason = "deduped" | "no-channels" | "no-recipient"
|
|
6
|
+
/** Every declared channel lacks a provider — a misconfiguration, not a preference. */
|
|
7
|
+
| "channel-unavailable";
|
|
8
|
+
type Recipient = {
|
|
9
|
+
userId: string;
|
|
10
|
+
email?: string | undefined;
|
|
11
|
+
phone?: string | undefined;
|
|
12
|
+
pushTokens?: readonly string[] | undefined;
|
|
13
|
+
timezone: string;
|
|
14
|
+
locale: string;
|
|
15
|
+
};
|
|
16
|
+
type NotificationRecord = {
|
|
17
|
+
id: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
type: string;
|
|
20
|
+
payload: unknown;
|
|
21
|
+
actorId: string | null;
|
|
22
|
+
groupKey: string | null;
|
|
23
|
+
dedupeKey: string | null;
|
|
24
|
+
seenAt: Date | null;
|
|
25
|
+
readAt: Date | null;
|
|
26
|
+
archivedAt: Date | null;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
};
|
|
29
|
+
type DeliveryRecord = {
|
|
30
|
+
id: string;
|
|
31
|
+
notificationId: string;
|
|
32
|
+
channel: Channel;
|
|
33
|
+
status: DeliveryStatus;
|
|
34
|
+
attempts: number;
|
|
35
|
+
maxAttempts: number;
|
|
36
|
+
notBefore: Date;
|
|
37
|
+
claimedAt: Date | null;
|
|
38
|
+
claimedBy: string | null;
|
|
39
|
+
lastError: string | null;
|
|
40
|
+
updatedAt: Date;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Scalar = string | number | boolean | Date | null;
|
|
44
|
+
/**
|
|
45
|
+
* One operator per condition. Operators are tagged objects so a bare value is
|
|
46
|
+
* always an equality test; json columns therefore cannot be filtered, since an
|
|
47
|
+
* object value would be ambiguous.
|
|
48
|
+
*/
|
|
49
|
+
type WhereCondition = Scalar | {
|
|
50
|
+
in: readonly (string | number)[];
|
|
51
|
+
} | {
|
|
52
|
+
lt: Date | number;
|
|
53
|
+
} | {
|
|
54
|
+
lte: Date | number;
|
|
55
|
+
} | {
|
|
56
|
+
gt: Date | number;
|
|
57
|
+
} | {
|
|
58
|
+
gte: Date | number;
|
|
59
|
+
} | {
|
|
60
|
+
not: Scalar;
|
|
61
|
+
};
|
|
62
|
+
type WhereClause = Record<string, WhereCondition>;
|
|
63
|
+
type QueryOptions = {
|
|
64
|
+
limit?: number;
|
|
65
|
+
orderBy?: {
|
|
66
|
+
field: string;
|
|
67
|
+
direction: "asc" | "desc";
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
type UpsertOptions = {
|
|
71
|
+
onConflict: readonly string[];
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Table access for plugins, scoped to the tables that plugin declared.
|
|
75
|
+
*
|
|
76
|
+
* Plugins could always declare tables via schema() but had no way to read
|
|
77
|
+
* them, so preferences had to bolt its queries onto the core adapter. That
|
|
78
|
+
* does not generalise to digests or push.
|
|
79
|
+
*/
|
|
80
|
+
type PluginStore = {
|
|
81
|
+
find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
|
|
82
|
+
insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
|
|
83
|
+
upsert(table: string, rows: readonly Record<string, unknown>[], options: UpsertOptions): Promise<number>;
|
|
84
|
+
update(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
85
|
+
remove(table: string, where: WhereClause): Promise<number>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type PreferenceRecord = {
|
|
89
|
+
userId: string;
|
|
90
|
+
type: string;
|
|
91
|
+
channel: Channel;
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
frequency: Frequency;
|
|
94
|
+
};
|
|
95
|
+
type InsertNotification = {
|
|
96
|
+
id: string;
|
|
97
|
+
userId: string;
|
|
98
|
+
type: string;
|
|
99
|
+
payload: unknown;
|
|
100
|
+
actorId?: string | undefined;
|
|
101
|
+
groupKey?: string | undefined;
|
|
102
|
+
dedupeKey?: string | undefined;
|
|
103
|
+
deliveries: readonly InsertDelivery[];
|
|
104
|
+
};
|
|
105
|
+
type InsertDelivery = {
|
|
106
|
+
id: string;
|
|
107
|
+
channel: Channel;
|
|
108
|
+
maxAttempts: number;
|
|
109
|
+
notBefore: Date;
|
|
110
|
+
};
|
|
111
|
+
type ClaimArgs = {
|
|
112
|
+
limit: number;
|
|
113
|
+
leaseMs: number;
|
|
114
|
+
/** Unique per call, not per worker. See RFC 0003 §4. */
|
|
115
|
+
claimToken: string;
|
|
116
|
+
channels?: readonly Channel[] | undefined;
|
|
117
|
+
/** Scopes the claim to one send; without it inline/deferred flush the whole table. */
|
|
118
|
+
ids?: readonly string[] | undefined;
|
|
119
|
+
now?: Date | undefined;
|
|
120
|
+
};
|
|
121
|
+
type ClaimedDelivery = {
|
|
122
|
+
id: string;
|
|
123
|
+
notificationId: string;
|
|
124
|
+
channel: Channel;
|
|
125
|
+
attempts: number;
|
|
126
|
+
maxAttempts: number;
|
|
127
|
+
notification: {
|
|
128
|
+
userId: string;
|
|
129
|
+
type: string;
|
|
130
|
+
payload: unknown;
|
|
131
|
+
actorId: string | null;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
type DeliveryOutcome = {
|
|
135
|
+
result: "sent";
|
|
136
|
+
} | {
|
|
137
|
+
result: "failed";
|
|
138
|
+
error: string;
|
|
139
|
+
retryable: boolean;
|
|
140
|
+
};
|
|
141
|
+
type DeliveryRelease = {
|
|
142
|
+
id: string;
|
|
143
|
+
claimToken: string;
|
|
144
|
+
outcome: DeliveryOutcome;
|
|
145
|
+
/** Applied when a retryable failure re-arms the row. */
|
|
146
|
+
nextAttemptAt?: Date | undefined;
|
|
147
|
+
};
|
|
148
|
+
type FeedQuery = {
|
|
149
|
+
userId: string;
|
|
150
|
+
limit: number;
|
|
151
|
+
cursor?: string | undefined;
|
|
152
|
+
unreadOnly?: boolean | undefined;
|
|
153
|
+
};
|
|
154
|
+
type FeedPage = {
|
|
155
|
+
notifications: readonly NotificationRecord[];
|
|
156
|
+
nextCursor: string | null;
|
|
157
|
+
};
|
|
158
|
+
type DatabaseAdapter = {
|
|
159
|
+
readonly name: string;
|
|
160
|
+
/**
|
|
161
|
+
* How the adapter names stored fields. SQL adapters use snake_case columns;
|
|
162
|
+
* document stores keep the declared camelCase. The plugin store translates
|
|
163
|
+
* accordingly rather than assuming one of them.
|
|
164
|
+
*/
|
|
165
|
+
readonly naming?: "snake_case" | "preserve";
|
|
166
|
+
/**
|
|
167
|
+
* True when json values must reach the driver as strings. postgres-js cannot
|
|
168
|
+
* bind a plain object; Mongo stores one natively.
|
|
169
|
+
*/
|
|
170
|
+
readonly serializesJson?: boolean;
|
|
171
|
+
/** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
|
|
172
|
+
createNotifications(rows: readonly InsertNotification[]): Promise<{
|
|
173
|
+
created: readonly string[];
|
|
174
|
+
deduped: readonly string[];
|
|
175
|
+
}>;
|
|
176
|
+
/** Atomic lease: a returned row stays invisible to other callers until it expires. */
|
|
177
|
+
claimPendingDeliveries(args: ClaimArgs): Promise<readonly ClaimedDelivery[]>;
|
|
178
|
+
/** Writes terminal state even when claimToken is stale. See RFC 0003 §6. */
|
|
179
|
+
releaseDeliveries(releases: readonly DeliveryRelease[]): Promise<void>;
|
|
180
|
+
listNotifications(query: FeedQuery): Promise<FeedPage>;
|
|
181
|
+
countUnseen(userId: string): Promise<number>;
|
|
182
|
+
markSeen(userId: string, before: Date): Promise<void>;
|
|
183
|
+
markRead(userId: string, notificationIds: readonly string[]): Promise<number>;
|
|
184
|
+
markAllRead(userId: string): Promise<number>;
|
|
185
|
+
getFailedDeliveries(args: {
|
|
186
|
+
since: Date;
|
|
187
|
+
limit: number;
|
|
188
|
+
}): Promise<readonly DeliveryRecord[]>;
|
|
189
|
+
/**
|
|
190
|
+
* Generic table access for plugin-declared tables. Reached only through a
|
|
191
|
+
* PluginStore, which validates the table and every column against the
|
|
192
|
+
* plugin's own schema() before anything gets here.
|
|
193
|
+
*/
|
|
194
|
+
queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
|
|
195
|
+
insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
|
|
196
|
+
updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
197
|
+
deleteRows(table: string, where: WhereClause): Promise<number>;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type { Channel as C, DatabaseAdapter as D, Frequency as F, InsertDelivery as I, NotificationRecord as N, PreferenceRecord as P, Recipient as R, SkipReason as S, DeliveryStatus as a, DeliveryMode as b, DeliveryRecord as c, ClaimArgs as d, ClaimedDelivery as e, DeliveryOutcome as f, DeliveryRelease as g, FeedPage as h, FeedQuery as i, InsertNotification as j, PluginStore as k };
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var drizzleOrm = require('drizzle-orm');
|
|
4
|
+
var pgCore = require('drizzle-orm/pg-core');
|
|
5
|
+
|
|
6
|
+
// src/adapters/drizzle/adapter.ts
|
|
7
|
+
|
|
8
|
+
// src/core/base64url.ts
|
|
9
|
+
function encodeBase64Url(input) {
|
|
10
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
11
|
+
let binary = "";
|
|
12
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
13
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
|
+
}
|
|
15
|
+
function decodeBase64UrlBytes(value) {
|
|
16
|
+
try {
|
|
17
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
18
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function decodeBase64Url(value) {
|
|
24
|
+
const bytes = decodeBase64UrlBytes(value);
|
|
25
|
+
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/schema/declaration.ts
|
|
29
|
+
var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
30
|
+
|
|
31
|
+
// src/core/store.ts
|
|
32
|
+
var OPERATORS = ["in", "lt", "lte", "gt", "gte", "not"];
|
|
33
|
+
var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => op in value);
|
|
34
|
+
|
|
35
|
+
// src/adapters/drizzle/adapter.ts
|
|
36
|
+
function toRows(result) {
|
|
37
|
+
if (Array.isArray(result)) return result;
|
|
38
|
+
if (result && typeof result === "object" && "rows" in result) {
|
|
39
|
+
const { rows } = result;
|
|
40
|
+
if (Array.isArray(rows)) return rows;
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
var str = (value) => String(value);
|
|
45
|
+
var nullableStr = (value) => value == null ? null : String(value);
|
|
46
|
+
var num = (value) => Number(value);
|
|
47
|
+
var date = (value) => value instanceof Date ? value : new Date(String(value));
|
|
48
|
+
var nullableDate = (value) => value == null ? null : value instanceof Date ? value : new Date(String(value));
|
|
49
|
+
var ts = (value) => value.toISOString();
|
|
50
|
+
var encodeCursor = (createdAt, id) => encodeBase64Url(`${createdAt.toISOString()}|${id}`);
|
|
51
|
+
function decodeCursor(cursor) {
|
|
52
|
+
const decoded = decodeBase64Url(cursor);
|
|
53
|
+
if (!decoded) return null;
|
|
54
|
+
const [iso, id] = decoded.split("|");
|
|
55
|
+
if (!iso || !id) return null;
|
|
56
|
+
const createdAt = new Date(iso);
|
|
57
|
+
return Number.isNaN(createdAt.getTime()) ? null : { createdAt, id };
|
|
58
|
+
}
|
|
59
|
+
function toNotification(row) {
|
|
60
|
+
return {
|
|
61
|
+
id: str(row.id),
|
|
62
|
+
userId: str(row.user_id),
|
|
63
|
+
type: str(row.type),
|
|
64
|
+
payload: row.payload,
|
|
65
|
+
actorId: nullableStr(row.actor_id),
|
|
66
|
+
groupKey: nullableStr(row.group_key),
|
|
67
|
+
dedupeKey: nullableStr(row.dedupe_key),
|
|
68
|
+
seenAt: nullableDate(row.seen_at),
|
|
69
|
+
readAt: nullableDate(row.read_at),
|
|
70
|
+
archivedAt: nullableDate(row.archived_at),
|
|
71
|
+
createdAt: date(row.created_at)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function toDelivery(row) {
|
|
75
|
+
return {
|
|
76
|
+
id: str(row.id),
|
|
77
|
+
notificationId: str(row.notification_id),
|
|
78
|
+
channel: str(row.channel),
|
|
79
|
+
status: str(row.status),
|
|
80
|
+
attempts: num(row.attempts),
|
|
81
|
+
maxAttempts: num(row.max_attempts),
|
|
82
|
+
notBefore: date(row.not_before),
|
|
83
|
+
claimedAt: nullableDate(row.claimed_at),
|
|
84
|
+
claimedBy: nullableStr(row.claimed_by),
|
|
85
|
+
lastError: nullableStr(row.last_error),
|
|
86
|
+
updatedAt: date(row.updated_at)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
var bind = (value) => value instanceof Date ? value.toISOString() : value;
|
|
90
|
+
function whereSql(where) {
|
|
91
|
+
const entries = Object.entries(where);
|
|
92
|
+
if (entries.length === 0) return drizzleOrm.sql``;
|
|
93
|
+
const conditions = entries.map(([field, condition]) => {
|
|
94
|
+
const column = drizzleOrm.sql.identifier(toSnakeCase(field));
|
|
95
|
+
if (condition === null) return drizzleOrm.sql`${column} IS NULL`;
|
|
96
|
+
if (!isOperator(condition)) return drizzleOrm.sql`${column} = ${bind(condition)}`;
|
|
97
|
+
const operator = condition;
|
|
98
|
+
if ("in" in operator) {
|
|
99
|
+
const list = operator.in;
|
|
100
|
+
if (list.length === 0) return drizzleOrm.sql`FALSE`;
|
|
101
|
+
return drizzleOrm.sql`${column} IN (${drizzleOrm.sql.join(
|
|
102
|
+
list.map((item) => drizzleOrm.sql`${item}`),
|
|
103
|
+
drizzleOrm.sql`, `
|
|
104
|
+
)})`;
|
|
105
|
+
}
|
|
106
|
+
if ("lt" in operator) return drizzleOrm.sql`${column} < ${bind(operator.lt)}`;
|
|
107
|
+
if ("lte" in operator) return drizzleOrm.sql`${column} <= ${bind(operator.lte)}`;
|
|
108
|
+
if ("gt" in operator) return drizzleOrm.sql`${column} > ${bind(operator.gt)}`;
|
|
109
|
+
if ("gte" in operator) return drizzleOrm.sql`${column} >= ${bind(operator.gte)}`;
|
|
110
|
+
if ("not" in operator) {
|
|
111
|
+
return operator.not === null ? drizzleOrm.sql`${column} IS NOT NULL` : drizzleOrm.sql`${column} IS DISTINCT FROM ${bind(operator.not)}`;
|
|
112
|
+
}
|
|
113
|
+
return drizzleOrm.sql`TRUE`;
|
|
114
|
+
});
|
|
115
|
+
return drizzleOrm.sql` WHERE ${drizzleOrm.sql.join(conditions, drizzleOrm.sql` AND `)}`;
|
|
116
|
+
}
|
|
117
|
+
function drizzleAdapter(client, options = {}) {
|
|
118
|
+
const db = client;
|
|
119
|
+
const prefix = options.prefix ?? "";
|
|
120
|
+
const NOTIFICATION = drizzleOrm.sql.identifier(`${prefix}notification`);
|
|
121
|
+
const DELIVERY = drizzleOrm.sql.identifier(`${prefix}notification_delivery`);
|
|
122
|
+
const run = async (query, on = db) => toRows(await on.execute(query));
|
|
123
|
+
return {
|
|
124
|
+
name: "drizzle-pg",
|
|
125
|
+
naming: "snake_case",
|
|
126
|
+
serializesJson: true,
|
|
127
|
+
async createNotifications(input) {
|
|
128
|
+
if (input.length === 0) return { created: [], deduped: [] };
|
|
129
|
+
const now = /* @__PURE__ */ new Date();
|
|
130
|
+
return db.transaction(async (tx) => {
|
|
131
|
+
const values = input.map(
|
|
132
|
+
(row) => drizzleOrm.sql`(
|
|
133
|
+
${row.id}::text,
|
|
134
|
+
${row.userId}::text,
|
|
135
|
+
${row.type}::text,
|
|
136
|
+
${JSON.stringify(row.payload ?? null)}::jsonb,
|
|
137
|
+
${row.actorId ?? null}::text,
|
|
138
|
+
${row.groupKey ?? null}::text,
|
|
139
|
+
${row.dedupeKey ?? null}::text,
|
|
140
|
+
${ts(now)}::timestamptz
|
|
141
|
+
)`
|
|
142
|
+
);
|
|
143
|
+
const inserted = await run(
|
|
144
|
+
drizzleOrm.sql`
|
|
145
|
+
INSERT INTO ${NOTIFICATION} (id, user_id, type, payload, actor_id, group_key, dedupe_key, created_at)
|
|
146
|
+
VALUES ${drizzleOrm.sql.join(values, drizzleOrm.sql`, `)}
|
|
147
|
+
ON CONFLICT (user_id, dedupe_key) DO NOTHING
|
|
148
|
+
RETURNING id
|
|
149
|
+
`,
|
|
150
|
+
tx
|
|
151
|
+
);
|
|
152
|
+
const created = new Set(inserted.map((row) => str(row.id)));
|
|
153
|
+
const deliveries = input.filter((row) => created.has(row.id)).flatMap(
|
|
154
|
+
(row) => row.deliveries.map(
|
|
155
|
+
(delivery) => drizzleOrm.sql`(
|
|
156
|
+
${delivery.id}::text,
|
|
157
|
+
${row.id}::text,
|
|
158
|
+
${delivery.channel}::text,
|
|
159
|
+
${delivery.maxAttempts}::integer,
|
|
160
|
+
${ts(delivery.notBefore)}::timestamptz,
|
|
161
|
+
${ts(now)}::timestamptz
|
|
162
|
+
)`
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
if (deliveries.length > 0) {
|
|
166
|
+
await tx.execute(drizzleOrm.sql`
|
|
167
|
+
INSERT INTO ${DELIVERY} (id, notification_id, channel, max_attempts, not_before, updated_at)
|
|
168
|
+
VALUES ${drizzleOrm.sql.join(deliveries, drizzleOrm.sql`, `)}
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
created: [...created],
|
|
173
|
+
deduped: input.filter((row) => !created.has(row.id)).map((row) => row.id)
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
async claimPendingDeliveries(args) {
|
|
178
|
+
const now = args.now ?? /* @__PURE__ */ new Date();
|
|
179
|
+
const staleBefore = new Date(now.getTime() - args.leaseMs);
|
|
180
|
+
const channelFilter = args.channels && args.channels.length > 0 ? drizzleOrm.sql`AND channel IN (${drizzleOrm.sql.join(
|
|
181
|
+
args.channels.map((channel) => drizzleOrm.sql`${channel}`),
|
|
182
|
+
drizzleOrm.sql`, `
|
|
183
|
+
)})` : drizzleOrm.sql``;
|
|
184
|
+
const idFilter = args.ids && args.ids.length > 0 ? drizzleOrm.sql`AND id IN (${drizzleOrm.sql.join(
|
|
185
|
+
args.ids.map((id) => drizzleOrm.sql`${id}`),
|
|
186
|
+
drizzleOrm.sql`, `
|
|
187
|
+
)})` : drizzleOrm.sql``;
|
|
188
|
+
const rows = await run(drizzleOrm.sql`
|
|
189
|
+
WITH claimed AS (
|
|
190
|
+
UPDATE ${DELIVERY}
|
|
191
|
+
SET status = 'claimed', claimed_at = ${ts(now)}::timestamptz, claimed_by = ${args.claimToken}::text
|
|
192
|
+
WHERE id IN (
|
|
193
|
+
SELECT id FROM ${DELIVERY}
|
|
194
|
+
WHERE (status = 'pending' OR (status = 'claimed' AND claimed_at < ${ts(staleBefore)}::timestamptz))
|
|
195
|
+
AND not_before <= ${ts(now)}::timestamptz
|
|
196
|
+
AND attempts < max_attempts
|
|
197
|
+
${channelFilter}
|
|
198
|
+
${idFilter}
|
|
199
|
+
ORDER BY not_before ASC, id ASC
|
|
200
|
+
LIMIT ${args.limit}
|
|
201
|
+
FOR UPDATE SKIP LOCKED
|
|
202
|
+
)
|
|
203
|
+
RETURNING id, notification_id, channel, attempts, max_attempts
|
|
204
|
+
)
|
|
205
|
+
SELECT c.id, c.notification_id, c.channel, c.attempts, c.max_attempts,
|
|
206
|
+
n.user_id, n.type, n.payload, n.actor_id
|
|
207
|
+
FROM claimed c
|
|
208
|
+
JOIN ${NOTIFICATION} n ON n.id = c.notification_id
|
|
209
|
+
`);
|
|
210
|
+
return rows.map((row) => ({
|
|
211
|
+
id: str(row.id),
|
|
212
|
+
notificationId: str(row.notification_id),
|
|
213
|
+
channel: str(row.channel),
|
|
214
|
+
attempts: num(row.attempts),
|
|
215
|
+
maxAttempts: num(row.max_attempts),
|
|
216
|
+
notification: {
|
|
217
|
+
userId: str(row.user_id),
|
|
218
|
+
type: str(row.type),
|
|
219
|
+
payload: row.payload,
|
|
220
|
+
actorId: nullableStr(row.actor_id)
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
},
|
|
224
|
+
async releaseDeliveries(releases) {
|
|
225
|
+
if (releases.length === 0) return;
|
|
226
|
+
const now = /* @__PURE__ */ new Date();
|
|
227
|
+
const values = releases.map(({ id, outcome, nextAttemptAt }) => {
|
|
228
|
+
const retryable = outcome.result === "failed" && outcome.retryable;
|
|
229
|
+
const error = outcome.result === "failed" ? outcome.error.slice(0, 2e3) : null;
|
|
230
|
+
return drizzleOrm.sql`(
|
|
231
|
+
${id}::text,
|
|
232
|
+
${outcome.result}::text,
|
|
233
|
+
${retryable}::boolean,
|
|
234
|
+
${error}::text,
|
|
235
|
+
${nextAttemptAt ? ts(nextAttemptAt) : null}::timestamptz
|
|
236
|
+
)`;
|
|
237
|
+
});
|
|
238
|
+
await db.execute(drizzleOrm.sql`
|
|
239
|
+
UPDATE ${DELIVERY} d
|
|
240
|
+
SET status = CASE
|
|
241
|
+
WHEN v.result = 'sent' THEN 'sent'
|
|
242
|
+
WHEN v.retryable AND d.attempts + 1 < d.max_attempts THEN 'pending'
|
|
243
|
+
ELSE 'failed'
|
|
244
|
+
END,
|
|
245
|
+
attempts = CASE WHEN v.result = 'sent' THEN d.attempts ELSE d.attempts + 1 END,
|
|
246
|
+
last_error = v.error,
|
|
247
|
+
not_before = COALESCE(v.not_before, d.not_before),
|
|
248
|
+
claimed_at = NULL,
|
|
249
|
+
claimed_by = NULL,
|
|
250
|
+
updated_at = ${ts(now)}::timestamptz
|
|
251
|
+
FROM (VALUES ${drizzleOrm.sql.join(values, drizzleOrm.sql`, `)}) AS v(id, result, retryable, error, not_before)
|
|
252
|
+
WHERE d.id = v.id
|
|
253
|
+
`);
|
|
254
|
+
},
|
|
255
|
+
async listNotifications(query) {
|
|
256
|
+
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
|
257
|
+
const rows = await run(drizzleOrm.sql`
|
|
258
|
+
SELECT * FROM ${NOTIFICATION}
|
|
259
|
+
WHERE user_id = ${query.userId}::text
|
|
260
|
+
AND archived_at IS NULL
|
|
261
|
+
${query.unreadOnly ? drizzleOrm.sql`AND read_at IS NULL` : drizzleOrm.sql``}
|
|
262
|
+
${cursor ? drizzleOrm.sql`AND (created_at, id) < (${ts(cursor.createdAt)}::timestamptz, ${cursor.id}::text)` : drizzleOrm.sql``}
|
|
263
|
+
ORDER BY created_at DESC, id DESC
|
|
264
|
+
LIMIT ${query.limit + 1}
|
|
265
|
+
`);
|
|
266
|
+
const page = rows.slice(0, query.limit).map(toNotification);
|
|
267
|
+
const last = page.at(-1);
|
|
268
|
+
return {
|
|
269
|
+
notifications: page,
|
|
270
|
+
nextCursor: rows.length > query.limit && last ? encodeCursor(last.createdAt, last.id) : null
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
async countUnseen(userId) {
|
|
274
|
+
const rows = await run(drizzleOrm.sql`
|
|
275
|
+
SELECT count(*)::int AS count FROM ${NOTIFICATION}
|
|
276
|
+
WHERE user_id = ${userId}::text AND seen_at IS NULL AND archived_at IS NULL
|
|
277
|
+
`);
|
|
278
|
+
return num(rows[0]?.count ?? 0);
|
|
279
|
+
},
|
|
280
|
+
async markSeen(userId, before) {
|
|
281
|
+
await db.execute(drizzleOrm.sql`
|
|
282
|
+
UPDATE ${NOTIFICATION} SET seen_at = ${ts(/* @__PURE__ */ new Date())}::timestamptz
|
|
283
|
+
WHERE user_id = ${userId}::text AND seen_at IS NULL AND created_at <= ${ts(before)}::timestamptz
|
|
284
|
+
`);
|
|
285
|
+
},
|
|
286
|
+
async markRead(userId, notificationIds) {
|
|
287
|
+
if (notificationIds.length === 0) return 0;
|
|
288
|
+
const rows = await run(drizzleOrm.sql`
|
|
289
|
+
UPDATE ${NOTIFICATION}
|
|
290
|
+
SET read_at = COALESCE(read_at, ${ts(/* @__PURE__ */ new Date())}::timestamptz)
|
|
291
|
+
WHERE user_id = ${userId}::text
|
|
292
|
+
AND id IN (${drizzleOrm.sql.join(
|
|
293
|
+
notificationIds.map((id) => drizzleOrm.sql`${id}`),
|
|
294
|
+
drizzleOrm.sql`, `
|
|
295
|
+
)})
|
|
296
|
+
RETURNING id
|
|
297
|
+
`);
|
|
298
|
+
return rows.length;
|
|
299
|
+
},
|
|
300
|
+
async markAllRead(userId) {
|
|
301
|
+
const rows = await run(drizzleOrm.sql`
|
|
302
|
+
UPDATE ${NOTIFICATION} SET read_at = ${ts(/* @__PURE__ */ new Date())}::timestamptz
|
|
303
|
+
WHERE user_id = ${userId}::text AND read_at IS NULL
|
|
304
|
+
RETURNING id
|
|
305
|
+
`);
|
|
306
|
+
return rows.length;
|
|
307
|
+
},
|
|
308
|
+
async queryTable(table, where, options2) {
|
|
309
|
+
const order = options2.orderBy ? drizzleOrm.sql` ORDER BY ${drizzleOrm.sql.identifier(toSnakeCase(options2.orderBy.field))} ${options2.orderBy.direction === "desc" ? drizzleOrm.sql`DESC` : drizzleOrm.sql`ASC`}` : drizzleOrm.sql``;
|
|
310
|
+
const limit = options2.limit ? drizzleOrm.sql` LIMIT ${options2.limit}` : drizzleOrm.sql``;
|
|
311
|
+
return run(
|
|
312
|
+
drizzleOrm.sql`SELECT * FROM ${drizzleOrm.sql.identifier(table)}${whereSql(where)}${order}${limit}`
|
|
313
|
+
);
|
|
314
|
+
},
|
|
315
|
+
async insertRows(table, rows, onConflict) {
|
|
316
|
+
if (rows.length === 0) return 0;
|
|
317
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
318
|
+
if (columns.length === 0) return 0;
|
|
319
|
+
const values = rows.map(
|
|
320
|
+
(row) => drizzleOrm.sql`(${drizzleOrm.sql.join(
|
|
321
|
+
columns.map((column) => drizzleOrm.sql`${bind(row[column])}`),
|
|
322
|
+
drizzleOrm.sql`, `
|
|
323
|
+
)})`
|
|
324
|
+
);
|
|
325
|
+
const conflict = onConflict && onConflict.length > 0 ? drizzleOrm.sql` ON CONFLICT (${drizzleOrm.sql.join(
|
|
326
|
+
onConflict.map((field) => drizzleOrm.sql.identifier(toSnakeCase(field))),
|
|
327
|
+
drizzleOrm.sql`, `
|
|
328
|
+
)}) DO UPDATE SET ${drizzleOrm.sql.join(
|
|
329
|
+
columns.filter((column) => !onConflict.includes(column)).map(
|
|
330
|
+
(column) => drizzleOrm.sql`${drizzleOrm.sql.identifier(toSnakeCase(column))} = EXCLUDED.${drizzleOrm.sql.identifier(
|
|
331
|
+
toSnakeCase(column)
|
|
332
|
+
)}`
|
|
333
|
+
),
|
|
334
|
+
drizzleOrm.sql`, `
|
|
335
|
+
)}` : drizzleOrm.sql``;
|
|
336
|
+
const inserted = await run(drizzleOrm.sql`
|
|
337
|
+
INSERT INTO ${drizzleOrm.sql.identifier(table)} (${drizzleOrm.sql.join(
|
|
338
|
+
columns.map((column) => drizzleOrm.sql.identifier(toSnakeCase(column))),
|
|
339
|
+
drizzleOrm.sql`, `
|
|
340
|
+
)})
|
|
341
|
+
VALUES ${drizzleOrm.sql.join(values, drizzleOrm.sql`, `)}${conflict}
|
|
342
|
+
RETURNING 1 AS ok
|
|
343
|
+
`);
|
|
344
|
+
return inserted.length;
|
|
345
|
+
},
|
|
346
|
+
async updateRows(table, where, set) {
|
|
347
|
+
const assignments = Object.entries(set).map(
|
|
348
|
+
([field, value]) => drizzleOrm.sql`${drizzleOrm.sql.identifier(toSnakeCase(field))} = ${bind(value)}`
|
|
349
|
+
);
|
|
350
|
+
if (assignments.length === 0) return 0;
|
|
351
|
+
const rows = await run(drizzleOrm.sql`
|
|
352
|
+
UPDATE ${drizzleOrm.sql.identifier(table)}
|
|
353
|
+
SET ${drizzleOrm.sql.join(assignments, drizzleOrm.sql`, `)}${whereSql(where)}
|
|
354
|
+
RETURNING 1 AS ok
|
|
355
|
+
`);
|
|
356
|
+
return rows.length;
|
|
357
|
+
},
|
|
358
|
+
async deleteRows(table, where) {
|
|
359
|
+
const rows = await run(
|
|
360
|
+
drizzleOrm.sql`DELETE FROM ${drizzleOrm.sql.identifier(table)}${whereSql(where)} RETURNING 1 AS ok`
|
|
361
|
+
);
|
|
362
|
+
return rows.length;
|
|
363
|
+
},
|
|
364
|
+
async getFailedDeliveries(args) {
|
|
365
|
+
const rows = await run(drizzleOrm.sql`
|
|
366
|
+
SELECT * FROM ${DELIVERY}
|
|
367
|
+
WHERE status = 'failed' AND updated_at >= ${ts(args.since)}::timestamptz
|
|
368
|
+
ORDER BY updated_at DESC
|
|
369
|
+
LIMIT ${args.limit}
|
|
370
|
+
`);
|
|
371
|
+
return rows.map(toDelivery);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function createSchema(prefix = "") {
|
|
376
|
+
const name = (base) => `${prefix}${base}`;
|
|
377
|
+
const notification = pgCore.pgTable(
|
|
378
|
+
name("notification"),
|
|
379
|
+
{
|
|
380
|
+
id: pgCore.text("id").primaryKey(),
|
|
381
|
+
userId: pgCore.text("user_id").notNull(),
|
|
382
|
+
type: pgCore.text("type").notNull(),
|
|
383
|
+
payload: pgCore.jsonb("payload").notNull(),
|
|
384
|
+
actorId: pgCore.text("actor_id"),
|
|
385
|
+
groupKey: pgCore.text("group_key"),
|
|
386
|
+
dedupeKey: pgCore.text("dedupe_key"),
|
|
387
|
+
seenAt: pgCore.timestamp("seen_at", { withTimezone: true }),
|
|
388
|
+
readAt: pgCore.timestamp("read_at", { withTimezone: true }),
|
|
389
|
+
archivedAt: pgCore.timestamp("archived_at", { withTimezone: true }),
|
|
390
|
+
createdAt: pgCore.timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
391
|
+
},
|
|
392
|
+
(table) => [
|
|
393
|
+
pgCore.index(name("notification_feed_idx")).on(table.userId, table.createdAt.desc()),
|
|
394
|
+
pgCore.index(name("notification_unseen_idx")).on(table.userId).where(drizzleOrm.sql`${table.seenAt} is null`),
|
|
395
|
+
pgCore.index(name("notification_group_idx")).on(table.userId, table.groupKey),
|
|
396
|
+
// Postgres treats NULLs as distinct here, so unlimited rows may carry no
|
|
397
|
+
// dedupe key while a present one is enforced unique per user.
|
|
398
|
+
pgCore.uniqueIndex(name("notification_dedupe_idx")).on(table.userId, table.dedupeKey)
|
|
399
|
+
]
|
|
400
|
+
);
|
|
401
|
+
const notificationDelivery = pgCore.pgTable(
|
|
402
|
+
name("notification_delivery"),
|
|
403
|
+
{
|
|
404
|
+
id: pgCore.text("id").primaryKey(),
|
|
405
|
+
notificationId: pgCore.text("notification_id").notNull().references(() => notification.id, { onDelete: "cascade" }),
|
|
406
|
+
channel: pgCore.text("channel").$type().notNull(),
|
|
407
|
+
status: pgCore.text("status").$type().notNull().default("pending"),
|
|
408
|
+
attempts: pgCore.integer("attempts").notNull().default(0),
|
|
409
|
+
maxAttempts: pgCore.integer("max_attempts").notNull().default(5),
|
|
410
|
+
notBefore: pgCore.timestamp("not_before", { withTimezone: true }).notNull().defaultNow(),
|
|
411
|
+
claimedAt: pgCore.timestamp("claimed_at", { withTimezone: true }),
|
|
412
|
+
claimedBy: pgCore.text("claimed_by"),
|
|
413
|
+
lastError: pgCore.text("last_error"),
|
|
414
|
+
updatedAt: pgCore.timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
415
|
+
},
|
|
416
|
+
(table) => [
|
|
417
|
+
pgCore.index(name("delivery_claim_idx")).on(table.status, table.notBefore),
|
|
418
|
+
pgCore.index(name("delivery_notification_idx")).on(table.notificationId)
|
|
419
|
+
]
|
|
420
|
+
);
|
|
421
|
+
const notificationPreference = pgCore.pgTable(
|
|
422
|
+
name("notification_preference"),
|
|
423
|
+
{
|
|
424
|
+
userId: pgCore.text("user_id").notNull(),
|
|
425
|
+
type: pgCore.text("type").notNull(),
|
|
426
|
+
channel: pgCore.text("channel").$type().notNull(),
|
|
427
|
+
enabled: pgCore.boolean("enabled").notNull().default(true),
|
|
428
|
+
frequency: pgCore.text("frequency").$type().notNull().default("instant")
|
|
429
|
+
},
|
|
430
|
+
(table) => [pgCore.primaryKey({ columns: [table.userId, table.type, table.channel] })]
|
|
431
|
+
);
|
|
432
|
+
return { notification, notificationDelivery, notificationPreference };
|
|
433
|
+
}
|
|
434
|
+
var schema = createSchema();
|
|
435
|
+
|
|
436
|
+
exports.createSchema = createSchema;
|
|
437
|
+
exports.drizzleAdapter = drizzleAdapter;
|
|
438
|
+
exports.schema = schema;
|
|
439
|
+
//# sourceMappingURL=drizzle.cjs.map
|
|
440
|
+
//# sourceMappingURL=drizzle.cjs.map
|