@dwk/microsub 0.1.0-beta.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 +15 -0
- package/README.md +92 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +102 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +102 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer.d.ts +40 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +87 -0
- package/dist/consumer.js.map +1 -0
- package/dist/discovery.d.ts +59 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +190 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +72 -0
- package/dist/fetch.js.map +1 -0
- package/dist/handler.d.ts +24 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +434 -0
- package/dist/handler.js.map +1 -0
- package/dist/hfeed.d.ts +25 -0
- package/dist/hfeed.d.ts.map +1 -0
- package/dist/hfeed.js +252 -0
- package/dist/hfeed.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jf2.d.ts +69 -0
- package/dist/jf2.d.ts.map +1 -0
- package/dist/jf2.js +295 -0
- package/dist/jf2.js.map +1 -0
- package/dist/log.d.ts +44 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +42 -0
- package/dist/log.js.map +1 -0
- package/dist/poll.d.ts +22 -0
- package/dist/poll.d.ts.map +1 -0
- package/dist/poll.js +39 -0
- package/dist/poll.js.map +1 -0
- package/dist/queue.d.ts +25 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +13 -0
- package/dist/queue.js.map +1 -0
- package/dist/replay.d.ts +34 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +49 -0
- package/dist/replay.js.map +1 -0
- package/dist/safe-fetch.d.ts +86 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +311 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/store.d.ts +131 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +393 -0
- package/dist/store.js.map +1 -0
- package/dist/xml.d.ts +51 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +196 -0
- package/dist/xml.js.map +1 -0
- package/package.json +49 -0
- package/src/auth.ts +184 -0
- package/src/config.ts +156 -0
- package/src/consumer.ts +140 -0
- package/src/discovery.ts +270 -0
- package/src/fetch.ts +82 -0
- package/src/handler.ts +594 -0
- package/src/hfeed.ts +287 -0
- package/src/index.ts +86 -0
- package/src/jf2.ts +394 -0
- package/src/log.ts +46 -0
- package/src/poll.ts +72 -0
- package/src/queue.ts +26 -0
- package/src/replay.ts +68 -0
- package/src/safe-fetch.ts +346 -0
- package/src/store.ts +644 -0
- package/src/xml.ts +229 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/microsub` — D1-backed authoritative state.
|
|
3
|
+
*
|
|
4
|
+
* Channels, the feeds each channel follows, the normalised JF2 timeline items,
|
|
5
|
+
* their per-item read flags, and a small per-feed poll cache (`ETag` /
|
|
6
|
+
* `Last-Modified`) all live here. This is correctness-sensitive state — a lost
|
|
7
|
+
* subscription or a dropped read flag is a bug, not a stale cache — so it lives
|
|
8
|
+
* in D1, a strongly-consistent store, and **never** KV (see
|
|
9
|
+
* `spec/non-functional-requirements.md`). The schema is created lazily on first
|
|
10
|
+
* use.
|
|
11
|
+
*
|
|
12
|
+
* Timeline paging uses a monotonic `seq` (an autoincrement rowid): the opaque
|
|
13
|
+
* `before` / `after` cursors a Microsub client round-trips are just `seq`
|
|
14
|
+
* values, so paging is stable under concurrent inserts and deletes.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
20
|
+
|
|
21
|
+
import type { Jf2Entry } from "./jf2";
|
|
22
|
+
|
|
23
|
+
/** The reserved channel that always exists and cannot be deleted or renamed. */
|
|
24
|
+
export const NOTIFICATIONS_CHANNEL = "notifications";
|
|
25
|
+
|
|
26
|
+
/** Cloudflare binding required by the Microsub store. */
|
|
27
|
+
export interface MicrosubStoreEnv {
|
|
28
|
+
/** D1 database holding channels, follows, items, and the poll cache. */
|
|
29
|
+
readonly MICROSUB_DB: D1Database;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A channel: its stable uid, display name, and sort position. */
|
|
33
|
+
export interface ChannelRecord {
|
|
34
|
+
readonly uid: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly position: number;
|
|
37
|
+
readonly createdAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A subscription within a channel. */
|
|
41
|
+
export interface FollowRecord {
|
|
42
|
+
/** The resolved feed URL polled by the server. */
|
|
43
|
+
readonly feedUrl: string;
|
|
44
|
+
/** The page URL the user originally followed (feed discovered from it). */
|
|
45
|
+
readonly pageUrl: string;
|
|
46
|
+
readonly createdAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A stored timeline item: the JF2 entry plus its bookkeeping. */
|
|
50
|
+
export interface StoredItem {
|
|
51
|
+
readonly seq: number;
|
|
52
|
+
readonly channel: string;
|
|
53
|
+
readonly entryId: string;
|
|
54
|
+
readonly feedUrl: string;
|
|
55
|
+
readonly isRead: boolean;
|
|
56
|
+
readonly entry: Jf2Entry;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A page of timeline items with the opaque cursors to fetch adjacent pages. */
|
|
60
|
+
export interface ItemPage {
|
|
61
|
+
readonly items: readonly StoredItem[];
|
|
62
|
+
readonly before?: string;
|
|
63
|
+
readonly after?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A cached conditional-request validator for a feed. */
|
|
67
|
+
export interface FeedCache {
|
|
68
|
+
readonly etag: string | null;
|
|
69
|
+
readonly lastModified: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for {@link MicrosubStore.listItems}. */
|
|
73
|
+
export interface ListOptions {
|
|
74
|
+
readonly before?: string;
|
|
75
|
+
readonly after?: string;
|
|
76
|
+
readonly limit: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Storage interface over the Microsub state. */
|
|
80
|
+
export interface MicrosubStore {
|
|
81
|
+
/** Create the schema if absent. Idempotent. */
|
|
82
|
+
init(): Promise<void>;
|
|
83
|
+
|
|
84
|
+
// Channels --------------------------------------------------------------
|
|
85
|
+
/** List channels in sort order, the reserved `notifications` channel first. */
|
|
86
|
+
listChannels(): Promise<ChannelRecord[]>;
|
|
87
|
+
/** Whether a channel with this uid exists. */
|
|
88
|
+
channelExists(uid: string): Promise<boolean>;
|
|
89
|
+
/** Create a channel with a freshly generated uid. */
|
|
90
|
+
createChannel(name: string, now: number): Promise<ChannelRecord>;
|
|
91
|
+
/** Rename a channel; returns the updated record, or `null` if unknown. */
|
|
92
|
+
renameChannel(uid: string, name: string): Promise<ChannelRecord | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Delete a channel and everything it owns (follows + items). Returns `false`
|
|
95
|
+
* if the uid is unknown or names the reserved channel.
|
|
96
|
+
*/
|
|
97
|
+
deleteChannel(uid: string): Promise<boolean>;
|
|
98
|
+
/** Apply a new sort order; uids absent from `order` keep their relative order after. */
|
|
99
|
+
reorderChannels(order: readonly string[]): Promise<void>;
|
|
100
|
+
|
|
101
|
+
// Follows ---------------------------------------------------------------
|
|
102
|
+
/** List a channel's subscriptions. */
|
|
103
|
+
listFollows(channel: string): Promise<FollowRecord[]>;
|
|
104
|
+
/** Add (or refresh) a subscription. */
|
|
105
|
+
addFollow(
|
|
106
|
+
channel: string,
|
|
107
|
+
feedUrl: string,
|
|
108
|
+
pageUrl: string,
|
|
109
|
+
now: number,
|
|
110
|
+
): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Remove a subscription, matching `url` against either the resolved feed URL
|
|
113
|
+
* or the page URL the user originally followed (a client unfollows with the
|
|
114
|
+
* URL it was given back, which is the page URL). Returns `false` if it was not
|
|
115
|
+
* present.
|
|
116
|
+
*/
|
|
117
|
+
removeFollow(channel: string, url: string): Promise<boolean>;
|
|
118
|
+
/** Every distinct feed URL across all channels (for the poller). */
|
|
119
|
+
distinctFeedUrls(): Promise<string[]>;
|
|
120
|
+
/** Channels that follow a given feed URL (for the consumer). */
|
|
121
|
+
channelsForFeed(feedUrl: string): Promise<string[]>;
|
|
122
|
+
|
|
123
|
+
// Items -----------------------------------------------------------------
|
|
124
|
+
/**
|
|
125
|
+
* Append entries to a channel, deduped by entry id. Returns the count newly
|
|
126
|
+
* inserted. Entries should be passed newest-last so the newest gets the
|
|
127
|
+
* highest `seq`. Reaps beyond `maxItems` oldest items afterwards.
|
|
128
|
+
*/
|
|
129
|
+
insertItems(
|
|
130
|
+
channel: string,
|
|
131
|
+
feedUrl: string,
|
|
132
|
+
entries: readonly Jf2Entry[],
|
|
133
|
+
now: number,
|
|
134
|
+
maxItems: number,
|
|
135
|
+
): Promise<number>;
|
|
136
|
+
/** A page of a channel's timeline. */
|
|
137
|
+
listItems(channel: string, options: ListOptions): Promise<ItemPage>;
|
|
138
|
+
/** Count of unread items in a channel. */
|
|
139
|
+
unreadCount(channel: string): Promise<number>;
|
|
140
|
+
/** Mark specific items read/unread. */
|
|
141
|
+
markRead(
|
|
142
|
+
channel: string,
|
|
143
|
+
entryIds: readonly string[],
|
|
144
|
+
read: boolean,
|
|
145
|
+
): Promise<void>;
|
|
146
|
+
/** Mark every item up to and including `entryId` (by `seq`) as read. */
|
|
147
|
+
markReadThrough(channel: string, entryId: string): Promise<void>;
|
|
148
|
+
/** Remove an item from a channel. Returns `false` if it was not present. */
|
|
149
|
+
removeItem(channel: string, entryId: string): Promise<boolean>;
|
|
150
|
+
/** Substring search over a channel's items (name + content). */
|
|
151
|
+
searchItems(
|
|
152
|
+
channel: string,
|
|
153
|
+
query: string,
|
|
154
|
+
limit: number,
|
|
155
|
+
): Promise<StoredItem[]>;
|
|
156
|
+
|
|
157
|
+
// Feed poll cache -------------------------------------------------------
|
|
158
|
+
/** The cached validators for a feed, or `null`. */
|
|
159
|
+
getFeedCache(feedUrl: string): Promise<FeedCache | null>;
|
|
160
|
+
/** Record the validators returned by a feed fetch. */
|
|
161
|
+
setFeedCache(
|
|
162
|
+
feedUrl: string,
|
|
163
|
+
etag: string | null,
|
|
164
|
+
lastModified: string | null,
|
|
165
|
+
now: number,
|
|
166
|
+
): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const SCHEMA: readonly string[] = [
|
|
170
|
+
`CREATE TABLE IF NOT EXISTS microsub_channels (
|
|
171
|
+
uid TEXT PRIMARY KEY,
|
|
172
|
+
name TEXT NOT NULL,
|
|
173
|
+
position INTEGER NOT NULL,
|
|
174
|
+
created_at INTEGER NOT NULL
|
|
175
|
+
)`,
|
|
176
|
+
`CREATE TABLE IF NOT EXISTS microsub_follows (
|
|
177
|
+
channel TEXT NOT NULL,
|
|
178
|
+
feed_url TEXT NOT NULL,
|
|
179
|
+
page_url TEXT NOT NULL,
|
|
180
|
+
created_at INTEGER NOT NULL,
|
|
181
|
+
PRIMARY KEY (channel, feed_url)
|
|
182
|
+
)`,
|
|
183
|
+
`CREATE TABLE IF NOT EXISTS microsub_items (
|
|
184
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
185
|
+
channel TEXT NOT NULL,
|
|
186
|
+
entry_id TEXT NOT NULL,
|
|
187
|
+
feed_url TEXT NOT NULL,
|
|
188
|
+
published INTEGER,
|
|
189
|
+
is_read INTEGER NOT NULL DEFAULT 0,
|
|
190
|
+
data TEXT NOT NULL,
|
|
191
|
+
created_at INTEGER NOT NULL,
|
|
192
|
+
UNIQUE (channel, entry_id)
|
|
193
|
+
)`,
|
|
194
|
+
`CREATE INDEX IF NOT EXISTS microsub_items_channel_seq
|
|
195
|
+
ON microsub_items (channel, seq)`,
|
|
196
|
+
`CREATE TABLE IF NOT EXISTS microsub_feeds (
|
|
197
|
+
feed_url TEXT PRIMARY KEY,
|
|
198
|
+
etag TEXT,
|
|
199
|
+
last_modified TEXT,
|
|
200
|
+
last_polled INTEGER
|
|
201
|
+
)`,
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
interface ChannelRow {
|
|
205
|
+
readonly uid: string;
|
|
206
|
+
readonly name: string;
|
|
207
|
+
readonly position: number;
|
|
208
|
+
readonly created_at: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface FollowRow {
|
|
212
|
+
readonly feed_url: string;
|
|
213
|
+
readonly page_url: string;
|
|
214
|
+
readonly created_at: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface ItemRow {
|
|
218
|
+
readonly seq: number;
|
|
219
|
+
readonly channel: string;
|
|
220
|
+
readonly entry_id: string;
|
|
221
|
+
readonly feed_url: string;
|
|
222
|
+
readonly is_read: number;
|
|
223
|
+
readonly data: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function channelFromRow(row: ChannelRow): ChannelRecord {
|
|
227
|
+
return {
|
|
228
|
+
uid: row.uid,
|
|
229
|
+
name: row.name,
|
|
230
|
+
position: row.position,
|
|
231
|
+
createdAt: row.created_at,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function itemFromRow(row: ItemRow): StoredItem {
|
|
236
|
+
const entry = JSON.parse(row.data) as Jf2Entry;
|
|
237
|
+
return {
|
|
238
|
+
seq: row.seq,
|
|
239
|
+
channel: row.channel,
|
|
240
|
+
entryId: row.entry_id,
|
|
241
|
+
feedUrl: row.feed_url,
|
|
242
|
+
isRead: row.is_read !== 0,
|
|
243
|
+
entry: { ...entry, _is_read: row.is_read !== 0 },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Parse a `published` ISO string to an epoch-seconds sort key, or `null`. */
|
|
248
|
+
function publishedToEpoch(entry: Jf2Entry): number | null {
|
|
249
|
+
if (!entry.published) return null;
|
|
250
|
+
const ms = Date.parse(entry.published);
|
|
251
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A short, URL-safe channel uid: base36 timestamp plus random suffix. */
|
|
255
|
+
function generateUid(): string {
|
|
256
|
+
const time = Date.now().toString(36);
|
|
257
|
+
const rand = Math.floor(Math.random() * 36 ** 5)
|
|
258
|
+
.toString(36)
|
|
259
|
+
.padStart(5, "0");
|
|
260
|
+
return `${time}${rand}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create the D1-backed {@link MicrosubStore}. Fails loudly if the required
|
|
265
|
+
* `MICROSUB_DB` binding is missing — no silent degradation (composition
|
|
266
|
+
* contract).
|
|
267
|
+
*/
|
|
268
|
+
export function createMicrosubStore(env: MicrosubStoreEnv): MicrosubStore {
|
|
269
|
+
if (!env.MICROSUB_DB) {
|
|
270
|
+
throw new Error("@dwk/microsub: missing required D1 binding `MICROSUB_DB`");
|
|
271
|
+
}
|
|
272
|
+
const db = env.MICROSUB_DB;
|
|
273
|
+
|
|
274
|
+
// The init promise is cached per store instance (not module-globally): the
|
|
275
|
+
// vitest-pool-workers test model resets D1 between tests while keeping one
|
|
276
|
+
// `env` singleton, so a global cache would skip re-creating the wiped tables.
|
|
277
|
+
// This matches `@dwk/websub`'s store. The reserved `notifications` channel is
|
|
278
|
+
// materialised as part of the same batch, so it always exists after any store
|
|
279
|
+
// operation — a client may hit `timeline`/`unread` for it on a fresh DB
|
|
280
|
+
// without first listing channels.
|
|
281
|
+
let ready: Promise<void> | null = null;
|
|
282
|
+
const ensureSchema = (): Promise<void> => {
|
|
283
|
+
// Clear the cached promise on failure so a transient D1 error during the
|
|
284
|
+
// first call doesn't permanently wedge the store.
|
|
285
|
+
ready ??= db
|
|
286
|
+
.batch([
|
|
287
|
+
...SCHEMA.map((ddl) => db.prepare(ddl)),
|
|
288
|
+
db
|
|
289
|
+
.prepare(
|
|
290
|
+
`INSERT OR IGNORE INTO microsub_channels (uid, name, position, created_at)
|
|
291
|
+
VALUES (?1, ?2, -1, ?3)`,
|
|
292
|
+
)
|
|
293
|
+
.bind(
|
|
294
|
+
NOTIFICATIONS_CHANNEL,
|
|
295
|
+
"Notifications",
|
|
296
|
+
Math.floor(Date.now() / 1000),
|
|
297
|
+
),
|
|
298
|
+
])
|
|
299
|
+
.then(() => undefined)
|
|
300
|
+
.catch((err: unknown) => {
|
|
301
|
+
ready = null;
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
return ready;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const seqOf = async (
|
|
308
|
+
channel: string,
|
|
309
|
+
entryId: string,
|
|
310
|
+
): Promise<number | null> => {
|
|
311
|
+
const row = await db
|
|
312
|
+
.prepare(
|
|
313
|
+
`SELECT seq FROM microsub_items WHERE channel = ?1 AND entry_id = ?2`,
|
|
314
|
+
)
|
|
315
|
+
.bind(channel, entryId)
|
|
316
|
+
.first<{ seq: number }>();
|
|
317
|
+
return row?.seq ?? null;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
async init() {
|
|
322
|
+
await ensureSchema();
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// Channels ------------------------------------------------------------
|
|
326
|
+
async listChannels() {
|
|
327
|
+
await ensureSchema();
|
|
328
|
+
const { results } = await db
|
|
329
|
+
.prepare(
|
|
330
|
+
`SELECT uid, name, position, created_at FROM microsub_channels
|
|
331
|
+
ORDER BY position ASC, created_at ASC`,
|
|
332
|
+
)
|
|
333
|
+
.all<ChannelRow>();
|
|
334
|
+
return results.map(channelFromRow);
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async channelExists(uid) {
|
|
338
|
+
await ensureSchema();
|
|
339
|
+
const row = await db
|
|
340
|
+
.prepare(`SELECT 1 AS present FROM microsub_channels WHERE uid = ?1`)
|
|
341
|
+
.bind(uid)
|
|
342
|
+
.first<{ present: number }>();
|
|
343
|
+
return row !== null;
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async createChannel(name, now) {
|
|
347
|
+
await ensureSchema();
|
|
348
|
+
const max = await db
|
|
349
|
+
.prepare(`SELECT MAX(position) AS maxPos FROM microsub_channels`)
|
|
350
|
+
.first<{ maxPos: number | null }>();
|
|
351
|
+
const position = (max?.maxPos ?? -1) + 1;
|
|
352
|
+
const uid = generateUid();
|
|
353
|
+
await db
|
|
354
|
+
.prepare(
|
|
355
|
+
`INSERT INTO microsub_channels (uid, name, position, created_at)
|
|
356
|
+
VALUES (?1, ?2, ?3, ?4)`,
|
|
357
|
+
)
|
|
358
|
+
.bind(uid, name, position, now)
|
|
359
|
+
.run();
|
|
360
|
+
return { uid, name, position, createdAt: now };
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
async renameChannel(uid, name) {
|
|
364
|
+
await ensureSchema();
|
|
365
|
+
if (uid === NOTIFICATIONS_CHANNEL) return null;
|
|
366
|
+
const result = await db
|
|
367
|
+
.prepare(`UPDATE microsub_channels SET name = ?2 WHERE uid = ?1`)
|
|
368
|
+
.bind(uid, name)
|
|
369
|
+
.run();
|
|
370
|
+
if (result.meta.changes === 0) return null;
|
|
371
|
+
const row = await db
|
|
372
|
+
.prepare(
|
|
373
|
+
`SELECT uid, name, position, created_at FROM microsub_channels WHERE uid = ?1`,
|
|
374
|
+
)
|
|
375
|
+
.bind(uid)
|
|
376
|
+
.first<ChannelRow>();
|
|
377
|
+
return row ? channelFromRow(row) : null;
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async deleteChannel(uid) {
|
|
381
|
+
await ensureSchema();
|
|
382
|
+
if (uid === NOTIFICATIONS_CHANNEL) return false;
|
|
383
|
+
const result = await db.batch([
|
|
384
|
+
db.prepare(`DELETE FROM microsub_channels WHERE uid = ?1`).bind(uid),
|
|
385
|
+
db.prepare(`DELETE FROM microsub_follows WHERE channel = ?1`).bind(uid),
|
|
386
|
+
db.prepare(`DELETE FROM microsub_items WHERE channel = ?1`).bind(uid),
|
|
387
|
+
]);
|
|
388
|
+
return (result[0]?.meta.changes ?? 0) > 0;
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
async reorderChannels(order) {
|
|
392
|
+
await ensureSchema();
|
|
393
|
+
const statements = order.map((uid, index) =>
|
|
394
|
+
db
|
|
395
|
+
.prepare(`UPDATE microsub_channels SET position = ?2 WHERE uid = ?1`)
|
|
396
|
+
.bind(uid, index),
|
|
397
|
+
);
|
|
398
|
+
if (statements.length > 0) await db.batch(statements);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
// Follows -------------------------------------------------------------
|
|
402
|
+
async listFollows(channel) {
|
|
403
|
+
await ensureSchema();
|
|
404
|
+
const { results } = await db
|
|
405
|
+
.prepare(
|
|
406
|
+
`SELECT feed_url, page_url, created_at FROM microsub_follows
|
|
407
|
+
WHERE channel = ?1 ORDER BY created_at ASC`,
|
|
408
|
+
)
|
|
409
|
+
.bind(channel)
|
|
410
|
+
.all<FollowRow>();
|
|
411
|
+
return results.map((row) => ({
|
|
412
|
+
feedUrl: row.feed_url,
|
|
413
|
+
pageUrl: row.page_url,
|
|
414
|
+
createdAt: row.created_at,
|
|
415
|
+
}));
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
async addFollow(channel, feedUrl, pageUrl, now) {
|
|
419
|
+
await ensureSchema();
|
|
420
|
+
await db
|
|
421
|
+
.prepare(
|
|
422
|
+
`INSERT INTO microsub_follows (channel, feed_url, page_url, created_at)
|
|
423
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
424
|
+
ON CONFLICT (channel, feed_url) DO UPDATE SET page_url = excluded.page_url`,
|
|
425
|
+
)
|
|
426
|
+
.bind(channel, feedUrl, pageUrl, now)
|
|
427
|
+
.run();
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async removeFollow(channel, url) {
|
|
431
|
+
await ensureSchema();
|
|
432
|
+
const result = await db
|
|
433
|
+
.prepare(
|
|
434
|
+
`DELETE FROM microsub_follows
|
|
435
|
+
WHERE channel = ?1 AND (feed_url = ?2 OR page_url = ?2)`,
|
|
436
|
+
)
|
|
437
|
+
.bind(channel, url)
|
|
438
|
+
.run();
|
|
439
|
+
return (result.meta.changes ?? 0) > 0;
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
async distinctFeedUrls() {
|
|
443
|
+
await ensureSchema();
|
|
444
|
+
const { results } = await db
|
|
445
|
+
.prepare(`SELECT DISTINCT feed_url FROM microsub_follows`)
|
|
446
|
+
.all<{ feed_url: string }>();
|
|
447
|
+
return results.map((row) => row.feed_url);
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
async channelsForFeed(feedUrl) {
|
|
451
|
+
await ensureSchema();
|
|
452
|
+
const { results } = await db
|
|
453
|
+
.prepare(`SELECT channel FROM microsub_follows WHERE feed_url = ?1`)
|
|
454
|
+
.bind(feedUrl)
|
|
455
|
+
.all<{ channel: string }>();
|
|
456
|
+
return results.map((row) => row.channel);
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
// Items ---------------------------------------------------------------
|
|
460
|
+
async insertItems(channel, feedUrl, entries, now, maxItems) {
|
|
461
|
+
await ensureSchema();
|
|
462
|
+
if (entries.length === 0) return 0;
|
|
463
|
+
const statements = entries.map((entry) =>
|
|
464
|
+
db
|
|
465
|
+
.prepare(
|
|
466
|
+
`INSERT OR IGNORE INTO microsub_items
|
|
467
|
+
(channel, entry_id, feed_url, published, is_read, data, created_at)
|
|
468
|
+
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)`,
|
|
469
|
+
)
|
|
470
|
+
.bind(
|
|
471
|
+
channel,
|
|
472
|
+
entry._id,
|
|
473
|
+
feedUrl,
|
|
474
|
+
publishedToEpoch(entry),
|
|
475
|
+
JSON.stringify(entry),
|
|
476
|
+
now,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
const results = await db.batch(statements);
|
|
480
|
+
const added = results.reduce((sum, r) => sum + (r.meta.changes ?? 0), 0);
|
|
481
|
+
|
|
482
|
+
// Retention: reap the oldest items past the ceiling so a runaway feed
|
|
483
|
+
// can't fill the store unbounded.
|
|
484
|
+
if (added > 0) {
|
|
485
|
+
await db
|
|
486
|
+
.prepare(
|
|
487
|
+
`DELETE FROM microsub_items
|
|
488
|
+
WHERE channel = ?1 AND seq NOT IN (
|
|
489
|
+
SELECT seq FROM microsub_items WHERE channel = ?1
|
|
490
|
+
ORDER BY seq DESC LIMIT ?2
|
|
491
|
+
)`,
|
|
492
|
+
)
|
|
493
|
+
.bind(channel, maxItems)
|
|
494
|
+
.run();
|
|
495
|
+
}
|
|
496
|
+
return added;
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
async listItems(channel, options) {
|
|
500
|
+
await ensureSchema();
|
|
501
|
+
const limit = Math.max(1, options.limit);
|
|
502
|
+
let rows: ItemRow[];
|
|
503
|
+
if (options.after !== undefined) {
|
|
504
|
+
const after = Number.parseInt(options.after, 10);
|
|
505
|
+
const { results } = await db
|
|
506
|
+
.prepare(
|
|
507
|
+
`SELECT seq, channel, entry_id, feed_url, is_read, data
|
|
508
|
+
FROM microsub_items WHERE channel = ?1 AND seq > ?2
|
|
509
|
+
ORDER BY seq ASC LIMIT ?3`,
|
|
510
|
+
)
|
|
511
|
+
.bind(channel, Number.isFinite(after) ? after : 0, limit)
|
|
512
|
+
.all<ItemRow>();
|
|
513
|
+
rows = results.slice().reverse();
|
|
514
|
+
} else {
|
|
515
|
+
const before =
|
|
516
|
+
options.before !== undefined
|
|
517
|
+
? Number.parseInt(options.before, 10)
|
|
518
|
+
: Number.MAX_SAFE_INTEGER;
|
|
519
|
+
const { results } = await db
|
|
520
|
+
.prepare(
|
|
521
|
+
`SELECT seq, channel, entry_id, feed_url, is_read, data
|
|
522
|
+
FROM microsub_items WHERE channel = ?1 AND seq < ?2
|
|
523
|
+
ORDER BY seq DESC LIMIT ?3`,
|
|
524
|
+
)
|
|
525
|
+
.bind(
|
|
526
|
+
channel,
|
|
527
|
+
Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER,
|
|
528
|
+
limit,
|
|
529
|
+
)
|
|
530
|
+
.all<ItemRow>();
|
|
531
|
+
rows = results;
|
|
532
|
+
}
|
|
533
|
+
const items = rows.map(itemFromRow);
|
|
534
|
+
const page: { items: StoredItem[]; before?: string; after?: string } = {
|
|
535
|
+
items,
|
|
536
|
+
};
|
|
537
|
+
if (items.length > 0) {
|
|
538
|
+
const last = items[items.length - 1];
|
|
539
|
+
const first = items[0];
|
|
540
|
+
// `before` advances to older entries. Offer it when the page filled
|
|
541
|
+
// (there may be more below), and always when paginating with `after`:
|
|
542
|
+
// even a partial newer-page sits above the entries we paged up from, so
|
|
543
|
+
// the client must be able to navigate back down. `after` always points
|
|
544
|
+
// past the newest seen.
|
|
545
|
+
if ((items.length === limit || options.after !== undefined) && last) {
|
|
546
|
+
page.before = String(last.seq);
|
|
547
|
+
}
|
|
548
|
+
if (first) page.after = String(first.seq);
|
|
549
|
+
}
|
|
550
|
+
return page;
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
async unreadCount(channel) {
|
|
554
|
+
await ensureSchema();
|
|
555
|
+
const row = await db
|
|
556
|
+
.prepare(
|
|
557
|
+
`SELECT COUNT(*) AS n FROM microsub_items WHERE channel = ?1 AND is_read = 0`,
|
|
558
|
+
)
|
|
559
|
+
.bind(channel)
|
|
560
|
+
.first<{ n: number }>();
|
|
561
|
+
return row?.n ?? 0;
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
async markRead(channel, entryIds, read) {
|
|
565
|
+
await ensureSchema();
|
|
566
|
+
if (entryIds.length === 0) return;
|
|
567
|
+
const flag = read ? 1 : 0;
|
|
568
|
+
const statements = entryIds.map((id) =>
|
|
569
|
+
db
|
|
570
|
+
.prepare(
|
|
571
|
+
`UPDATE microsub_items SET is_read = ?3 WHERE channel = ?1 AND entry_id = ?2`,
|
|
572
|
+
)
|
|
573
|
+
.bind(channel, id, flag),
|
|
574
|
+
);
|
|
575
|
+
await db.batch(statements);
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
async markReadThrough(channel, entryId) {
|
|
579
|
+
await ensureSchema();
|
|
580
|
+
const seq = await seqOf(channel, entryId);
|
|
581
|
+
if (seq === null) return;
|
|
582
|
+
await db
|
|
583
|
+
.prepare(
|
|
584
|
+
`UPDATE microsub_items SET is_read = 1 WHERE channel = ?1 AND seq <= ?2`,
|
|
585
|
+
)
|
|
586
|
+
.bind(channel, seq)
|
|
587
|
+
.run();
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
async removeItem(channel, entryId) {
|
|
591
|
+
await ensureSchema();
|
|
592
|
+
const result = await db
|
|
593
|
+
.prepare(
|
|
594
|
+
`DELETE FROM microsub_items WHERE channel = ?1 AND entry_id = ?2`,
|
|
595
|
+
)
|
|
596
|
+
.bind(channel, entryId)
|
|
597
|
+
.run();
|
|
598
|
+
return (result.meta.changes ?? 0) > 0;
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
async searchItems(channel, query, limit) {
|
|
602
|
+
await ensureSchema();
|
|
603
|
+
const like = `%${query.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
|
|
604
|
+
const { results } = await db
|
|
605
|
+
.prepare(
|
|
606
|
+
`SELECT seq, channel, entry_id, feed_url, is_read, data
|
|
607
|
+
FROM microsub_items
|
|
608
|
+
WHERE channel = ?1 AND data LIKE ?2 ESCAPE '\\'
|
|
609
|
+
ORDER BY seq DESC LIMIT ?3`,
|
|
610
|
+
)
|
|
611
|
+
.bind(channel, like, Math.max(1, limit))
|
|
612
|
+
.all<ItemRow>();
|
|
613
|
+
return results.map(itemFromRow);
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
// Feed poll cache -----------------------------------------------------
|
|
617
|
+
async getFeedCache(feedUrl) {
|
|
618
|
+
await ensureSchema();
|
|
619
|
+
const row = await db
|
|
620
|
+
.prepare(
|
|
621
|
+
`SELECT etag, last_modified FROM microsub_feeds WHERE feed_url = ?1`,
|
|
622
|
+
)
|
|
623
|
+
.bind(feedUrl)
|
|
624
|
+
.first<{ etag: string | null; last_modified: string | null }>();
|
|
625
|
+
if (row === null) return null;
|
|
626
|
+
return { etag: row.etag, lastModified: row.last_modified };
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
async setFeedCache(feedUrl, etag, lastModified, now) {
|
|
630
|
+
await ensureSchema();
|
|
631
|
+
await db
|
|
632
|
+
.prepare(
|
|
633
|
+
`INSERT INTO microsub_feeds (feed_url, etag, last_modified, last_polled)
|
|
634
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
635
|
+
ON CONFLICT (feed_url) DO UPDATE SET
|
|
636
|
+
etag = excluded.etag,
|
|
637
|
+
last_modified = excluded.last_modified,
|
|
638
|
+
last_polled = excluded.last_polled`,
|
|
639
|
+
)
|
|
640
|
+
.bind(feedUrl, etag, lastModified, now)
|
|
641
|
+
.run();
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|