@propertysimple/client 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.
Files changed (54) hide show
  1. package/API.md +79 -0
  2. package/README.md +348 -0
  3. package/client/src/directory-sync.d.ts +123 -0
  4. package/client/src/directory-sync.js +404 -0
  5. package/client/src/index.d.ts +5123 -0
  6. package/client/src/index.js +285 -0
  7. package/client/src/managed-sync.d.ts +129 -0
  8. package/client/src/managed-sync.js +410 -0
  9. package/contracts/src/catalog-derived.d.ts +7 -0
  10. package/contracts/src/catalog-derived.js +78 -0
  11. package/contracts/src/catalog-fields.d.ts +231 -0
  12. package/contracts/src/catalog-fields.js +194 -0
  13. package/contracts/src/catalog-inventory.d.ts +14 -0
  14. package/contracts/src/catalog-inventory.js +10 -0
  15. package/contracts/src/directory-execution.d.ts +320 -0
  16. package/contracts/src/directory-execution.js +122 -0
  17. package/contracts/src/directory-identity.d.ts +1255 -0
  18. package/contracts/src/directory-identity.js +129 -0
  19. package/contracts/src/directory-sales.d.ts +333 -0
  20. package/contracts/src/directory-sales.js +102 -0
  21. package/contracts/src/directory.d.ts +1088 -0
  22. package/contracts/src/directory.js +179 -0
  23. package/contracts/src/index.d.ts +18 -0
  24. package/contracts/src/index.js +18 -0
  25. package/contracts/src/intelligence.d.ts +366 -0
  26. package/contracts/src/intelligence.js +157 -0
  27. package/contracts/src/location-serving.d.ts +13 -0
  28. package/contracts/src/location-serving.js +89 -0
  29. package/contracts/src/location.d.ts +73 -0
  30. package/contracts/src/location.js +75 -0
  31. package/contracts/src/openapi.d.ts +22 -0
  32. package/contracts/src/openapi.js +109 -0
  33. package/contracts/src/operations.d.ts +8316 -0
  34. package/contracts/src/operations.js +56 -0
  35. package/contracts/src/opportunity-events.d.ts +38 -0
  36. package/contracts/src/opportunity-events.js +69 -0
  37. package/contracts/src/platform-api.d.ts +12 -0
  38. package/contracts/src/platform-api.js +1 -0
  39. package/contracts/src/professional-intelligence.d.ts +256 -0
  40. package/contracts/src/professional-intelligence.js +90 -0
  41. package/contracts/src/property-features.d.ts +94 -0
  42. package/contracts/src/property-features.js +398 -0
  43. package/contracts/src/publication.d.ts +44 -0
  44. package/contracts/src/publication.js +42 -0
  45. package/contracts/src/routes.d.ts +437 -0
  46. package/contracts/src/routes.js +496 -0
  47. package/contracts/src/schemas.d.ts +3198 -0
  48. package/contracts/src/schemas.js +674 -0
  49. package/contracts/src/search.d.ts +2630 -0
  50. package/contracts/src/search.js +223 -0
  51. package/contracts/src/smart-feed.d.ts +3731 -0
  52. package/contracts/src/smart-feed.js +109 -0
  53. package/openapi.json +43542 -0
  54. package/package.json +60 -0
@@ -0,0 +1,410 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CatalogSourceFreshnessSchema, ListingSchema, operations, } from '../../contracts/src/index.js';
3
+ import { ApiError } from './index.js';
4
+ import { freshLocationProjection } from '../../contracts/src/location-serving.js';
5
+ export const portalActiveProfile = {
6
+ id: 'portal-active-v1',
7
+ eligible: (listing) => listing.display_eligibility &&
8
+ listing.source_availability === 'present' &&
9
+ listing.market_status === 'active',
10
+ };
11
+ export const allVisibleProfile = {
12
+ id: 'all-visible-v1',
13
+ eligible: (listing) => listing.display_eligibility && listing.source_availability === 'present',
14
+ };
15
+ export class ProjectionUnavailableError extends Error {
16
+ constructor(message = 'Catalog projection is initializing, blocked, or outside its freshness budget.') {
17
+ super(message);
18
+ this.name = 'ProjectionUnavailableError';
19
+ }
20
+ }
21
+ const newCheckpoint = () => ({
22
+ phase: 'create',
23
+ generation: randomUUID(),
24
+ lastSequence: '0',
25
+ stagedCount: 0,
26
+ });
27
+ const sequence = (value) => {
28
+ if (!/^\d+$/.test(value))
29
+ throw new Error('Catalog sequence must be a nonnegative integer.');
30
+ return BigInt(value);
31
+ };
32
+ /**
33
+ * An isolated consumer-owned projection. Product users, saved homes and canonical URLs stay in
34
+ * product tables, joined using local_id. Never point this adapter at a product's listings table.
35
+ */
36
+ export class PostgresCatalogTarget {
37
+ db;
38
+ configuration;
39
+ constructor(db, configuration) {
40
+ this.db = db;
41
+ this.configuration = configuration;
42
+ for (const value of [
43
+ configuration.streamId,
44
+ configuration.datasetId,
45
+ configuration.sourceNamespace,
46
+ configuration.profile.id,
47
+ ])
48
+ if (!value || value.length > 256)
49
+ throw new Error('Sync identities must contain 1–256 characters.');
50
+ }
51
+ /** Explicit schema installation. Run only against an approved consumer database. */
52
+ async initialize() {
53
+ await this.db.query(`
54
+ create schema if not exists consumer_sync;
55
+ create table if not exists consumer_sync.streams (
56
+ id text primary key, dataset_id text not null, source_namespace text not null,
57
+ profile_id text not null, version bigint not null default 0, checkpoint jsonb not null,
58
+ active_generation text, synchronized_at timestamptz
59
+ );
60
+ create table if not exists consumer_sync.identities (
61
+ stream_id text not null references consumer_sync.streams(id), listing_id text not null,
62
+ local_id uuid not null, primary key(stream_id,listing_id), unique(stream_id,local_id)
63
+ );
64
+ create table if not exists consumer_sync.projections (
65
+ stream_id text not null references consumer_sync.streams(id), generation text not null,
66
+ listing_id text not null, payload jsonb, eligible boolean not null,
67
+ primary key(stream_id,generation,listing_id)
68
+ );
69
+ create table if not exists consumer_sync.invalidations (
70
+ id uuid primary key, stream_id text not null references consumer_sync.streams(id),
71
+ generation text not null, kind text not null check(kind in ('reset','upsert','remove')),
72
+ listing_id text, local_id uuid, created_at timestamptz not null default now(),
73
+ lease_token uuid, lease_until timestamptz, acknowledged_at timestamptz
74
+ );
75
+ create index if not exists consumer_sync_invalidations_pending
76
+ on consumer_sync.invalidations(stream_id,created_at) where acknowledged_at is null;
77
+ alter table consumer_sync.streams enable row level security;
78
+ alter table consumer_sync.identities enable row level security;
79
+ alter table consumer_sync.projections enable row level security;
80
+ alter table consumer_sync.invalidations enable row level security;
81
+ revoke all on schema consumer_sync from public;
82
+ revoke all on consumer_sync.streams,consumer_sync.identities,
83
+ consumer_sync.projections,consumer_sync.invalidations from public;
84
+ `);
85
+ await this.db.query(`insert into consumer_sync.streams(id,dataset_id,source_namespace,profile_id,checkpoint)
86
+ values($1,$2,$3,$4,$5::jsonb) on conflict(id) do nothing`, [
87
+ this.configuration.streamId,
88
+ this.configuration.datasetId,
89
+ this.configuration.sourceNamespace,
90
+ this.configuration.profile.id,
91
+ JSON.stringify(newCheckpoint()),
92
+ ]);
93
+ await this.state();
94
+ }
95
+ async state() {
96
+ const row = (await this.db.query(`select *,version::text from consumer_sync.streams where id=$1`, [
97
+ this.configuration.streamId,
98
+ ])).rows[0];
99
+ if (!row)
100
+ throw new Error('Initialize the consumer projection before synchronization.');
101
+ if (row.dataset_id !== this.configuration.datasetId ||
102
+ row.source_namespace !== this.configuration.sourceNamespace ||
103
+ row.profile_id !== this.configuration.profile.id)
104
+ throw new Error('Sync scope/profile changed. Use a new stream and an explicit product cutover.');
105
+ return row;
106
+ }
107
+ /** Adopt an existing product UUID before importing it; never rewrite an established mapping. */
108
+ async mapExistingIdentity(listingId, localId) {
109
+ await this.db.transaction(async (tx) => {
110
+ await tx.query(`insert into consumer_sync.identities(stream_id,listing_id,local_id) values($1,$2,$3::uuid) on conflict(stream_id,listing_id) do nothing`, [this.configuration.streamId, listingId, localId]);
111
+ const saved = (await tx.query(`select local_id from consumer_sync.identities where stream_id=$1 and listing_id=$2`, [this.configuration.streamId, listingId])).rows[0];
112
+ if (saved.local_id !== localId.toLowerCase())
113
+ throw new Error('An existing listing identity cannot be remapped.');
114
+ });
115
+ }
116
+ /** Readers never see staging generations. A caller must choose an explicit freshness budget. */
117
+ async list(options) {
118
+ const limit = options.limit ?? 100;
119
+ if (!Number.isInteger(limit) ||
120
+ limit < 1 ||
121
+ limit > 200 ||
122
+ !Number.isFinite(options.maxStalenessMs) ||
123
+ options.maxStalenessMs <= 0)
124
+ throw new Error('Use a page of 1–200 rows and a positive freshness budget.');
125
+ return this.db.transaction(async (tx) => {
126
+ const at = await this.assertReadable(tx, options);
127
+ return (await tx.query(`select p.listing_id,i.local_id,p.payload as listing from consumer_sync.streams s
128
+ join consumer_sync.projections p on p.stream_id=s.id and p.generation=s.active_generation
129
+ join consumer_sync.identities i on i.stream_id=p.stream_id and i.listing_id=p.listing_id
130
+ where s.id=$1 and p.eligible and p.listing_id>$2 and s.synchronized_at>=now()-($3::double precision * interval '1 millisecond')
131
+ order by p.listing_id limit $4`, [this.configuration.streamId, options.after ?? '', options.maxStalenessMs, limit])).rows.map((row) => ({ ...row, listing: freshLocationProjection(row.listing, at) }));
132
+ });
133
+ }
134
+ /** Status consumers may inspect a noneligible upsert; a source tombstone has a null payload. */
135
+ async get(listingId, options) {
136
+ if (!Number.isFinite(options.maxStalenessMs) || options.maxStalenessMs <= 0)
137
+ throw new Error('Choose a positive freshness budget.');
138
+ return this.db.transaction(async (tx) => {
139
+ const at = await this.assertReadable(tx, options);
140
+ const row = (await tx.query(`select p.listing_id,i.local_id,p.eligible,p.payload as listing from consumer_sync.streams s
141
+ join consumer_sync.projections p on p.stream_id=s.id and p.generation=s.active_generation
142
+ join consumer_sync.identities i on i.stream_id=p.stream_id and i.listing_id=p.listing_id
143
+ where s.id=$1 and p.listing_id=$2`, [this.configuration.streamId, listingId])).rows[0] ?? null;
144
+ return row?.listing ? { ...row, listing: freshLocationProjection(row.listing, at) } : row;
145
+ });
146
+ }
147
+ async assertReadable(tx, options) {
148
+ for (const bound of [
149
+ options.maxStalenessMs,
150
+ options.maxSourceStalenessMs,
151
+ options.maxFullEnumerationStalenessMs,
152
+ ])
153
+ if (bound !== undefined && (!Number.isFinite(bound) || bound <= 0))
154
+ throw new Error('Freshness bounds must be positive.');
155
+ const row = (await tx.query(`select active_generation is not null and synchronized_at>=now()-($2::double precision * interval '1 millisecond') as ready,checkpoint,now() as now_at from consumer_sync.streams where id=$1 for share`, [this.configuration.streamId, options.maxStalenessMs])).rows[0];
156
+ if (!row?.ready)
157
+ throw new ProjectionUnavailableError();
158
+ const sourceBounds = [
159
+ ['last_completed_enumeration_at', options.maxSourceStalenessMs],
160
+ ['last_full_enumeration_at', options.maxFullEnumerationStalenessMs],
161
+ ];
162
+ const now = new Date(row.now_at).getTime();
163
+ for (const [field, bound] of sourceBounds) {
164
+ if (bound === undefined)
165
+ continue;
166
+ if (!row.checkpoint.sourceFreshness?.length ||
167
+ row.checkpoint.sourceFreshness.some((source) => {
168
+ const at = source[field] ? Date.parse(source[field]) : NaN;
169
+ return !Number.isFinite(at) || now - at > bound || at > now + 60_000;
170
+ }))
171
+ throw new ProjectionUnavailableError(`Source coverage is unknown or outside the ${field} freshness budget.`);
172
+ }
173
+ return new Date(row.now_at);
174
+ }
175
+ async commit(expected, next, mutate, synchronized = false) {
176
+ return this.db.transaction(async (tx) => {
177
+ // The checkpoint version fences workers that fetched the same page concurrently. Provider
178
+ // HTTP happens before this lock; a losing worker commits neither rows nor a stale cursor.
179
+ const locked = (await tx.query(`select version::text from consumer_sync.streams where id=$1 for update`, [this.configuration.streamId])).rows[0];
180
+ if (locked.version !== expected.version)
181
+ return false;
182
+ await mutate(tx);
183
+ await tx.query(`update consumer_sync.streams set checkpoint=$2::jsonb,version=version+1,synchronized_at=case when $3 then now() else synchronized_at end where id=$1`, [this.configuration.streamId, JSON.stringify(next), synchronized]);
184
+ return true;
185
+ });
186
+ }
187
+ async write(tx, generation, listing, listingId, invalidate, snapshot = false) {
188
+ if (listing && listing.id !== listingId)
189
+ throw new Error('Catalog listing identity mismatch.');
190
+ await tx.query(`insert into consumer_sync.identities(stream_id,listing_id,local_id) values($1,$2,$3::uuid) on conflict(stream_id,listing_id) do nothing`, [this.configuration.streamId, listingId, randomUUID()]);
191
+ const eligible = listing !== null && this.configuration.profile.eligible(listing);
192
+ // Noneligible upserts are removals from this projection. Do not leave a previously active
193
+ // property visible merely because the upstream change used the word "upsert".
194
+ await tx.query(`insert into consumer_sync.projections(stream_id,generation,listing_id,payload,eligible) values($1,$2,$3,$4::jsonb,$5) ${snapshot ? '' : 'on conflict(stream_id,generation,listing_id) do update set payload=excluded.payload,eligible=excluded.eligible'}`, [
195
+ this.configuration.streamId,
196
+ generation,
197
+ listingId,
198
+ listing ? JSON.stringify(listing) : null,
199
+ eligible,
200
+ ]);
201
+ if (invalidate)
202
+ await this.invalidate(tx, generation, eligible ? 'upsert' : 'remove', listingId);
203
+ }
204
+ async activate(tx, generation) {
205
+ // A pointer swap keeps million-row bootstrap completion constant-sized. Consumers of the
206
+ // outbox must treat reset as a whole-catalog cache/index refresh, including old missing IDs.
207
+ await tx.query(`update consumer_sync.streams set active_generation=$2 where id=$1`, [
208
+ this.configuration.streamId,
209
+ generation,
210
+ ]);
211
+ await this.invalidate(tx, generation, 'reset');
212
+ }
213
+ async invalidate(tx, generation, kind, listingId) {
214
+ await tx.query(`insert into consumer_sync.invalidations(id,stream_id,generation,kind,listing_id,local_id) values($1::uuid,$2,$3,$4,$5,(select local_id from consumer_sync.identities where stream_id=$2 and listing_id=$5))`, [randomUUID(), this.configuration.streamId, generation, kind, listingId ?? null]);
215
+ }
216
+ async reset(expected, blocked = false) {
217
+ const next = newCheckpoint();
218
+ if (blocked)
219
+ next.phase = 'blocked';
220
+ return this.commit(expected, next, async (tx) => {
221
+ // An expired scope might remove access. Hide the old projection immediately instead of
222
+ // serving data that the new snapshot may no longer authorize.
223
+ await tx.query(`update consumer_sync.streams set active_generation=null,synchronized_at=null where id=$1`, [this.configuration.streamId]);
224
+ await this.invalidate(tx, next.generation, 'reset');
225
+ });
226
+ }
227
+ /** Explicit operator recovery after correcting denied credentials/scope. Does not change scope. */
228
+ async resume() {
229
+ const state = await this.state();
230
+ if (state.checkpoint.phase !== 'blocked')
231
+ throw new Error('Only a blocked stream needs resume.');
232
+ if (!(await this.reset(state)))
233
+ throw new Error('Stream changed concurrently; inspect before retry.');
234
+ }
235
+ async claimInvalidations(limit = 100, leaseSeconds = 60) {
236
+ if (!Number.isInteger(limit) ||
237
+ limit < 1 ||
238
+ limit > 200 ||
239
+ !Number.isInteger(leaseSeconds) ||
240
+ leaseSeconds < 1 ||
241
+ leaseSeconds > 3600)
242
+ throw new Error('Invalid outbox page/lease bound.');
243
+ const token = randomUUID();
244
+ return (await this.db.query(`with picked as (
245
+ select id from consumer_sync.invalidations where stream_id=$1 and acknowledged_at is null
246
+ and (lease_until is null or lease_until<now()) order by created_at,id for update skip locked limit $2
247
+ ) update consumer_sync.invalidations o set lease_token=$3::uuid,lease_until=now()+($4::integer*interval '1 second')
248
+ from picked where o.id=picked.id returning o.*`, [this.configuration.streamId, limit, token, leaseSeconds])).rows;
249
+ }
250
+ async acknowledgeInvalidation(id, leaseToken) {
251
+ return ((await this.db.query(`update consumer_sync.invalidations set acknowledged_at=now() where id=$1::uuid and stream_id=$2 and lease_token=$3::uuid and acknowledged_at is null and lease_until>=now()`, [id, this.configuration.streamId, leaseToken])).rowCount === 1);
252
+ }
253
+ /** Bounded retention removes obsolete listing payloads, preserving identities and product data. */
254
+ async pruneObsolete(limit = 200) {
255
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
256
+ throw new Error('Invalid cleanup bound.');
257
+ return this.db.transaction(async (tx) => {
258
+ await tx.query(`select id from consumer_sync.streams where id=$1 for update`, [
259
+ this.configuration.streamId,
260
+ ]);
261
+ return (await tx.query(`delete from consumer_sync.projections where (stream_id,generation,listing_id) in (
262
+ select p.stream_id,p.generation,p.listing_id from consumer_sync.projections p join consumer_sync.streams s on s.id=p.stream_id
263
+ where s.id=$1 and p.generation is distinct from s.active_generation and p.generation<>s.checkpoint->>'generation' limit $2
264
+ )`, [this.configuration.streamId, limit])).rowCount;
265
+ });
266
+ }
267
+ }
268
+ /** One step makes at most one network request and commits at most one bounded page. */
269
+ export class CatalogSyncWorker {
270
+ source;
271
+ target;
272
+ pageSize;
273
+ constructor(source, target, pageSize = 100) {
274
+ this.source = source;
275
+ this.target = target;
276
+ this.pageSize = pageSize;
277
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200)
278
+ throw new Error('Page size must be 1–200.');
279
+ }
280
+ async step(options = {}) {
281
+ const state = await this.target.state();
282
+ const checkpoint = state.checkpoint;
283
+ const result = (status, applied = 0) => ({
284
+ status,
285
+ phase: checkpoint.phase,
286
+ applied,
287
+ });
288
+ if (checkpoint.phase === 'blocked')
289
+ return result('blocked');
290
+ try {
291
+ if (checkpoint.phase === 'create' || checkpoint.phase === 'waiting') {
292
+ const snapshot = operations.getSnapshot.output.parse(checkpoint.phase === 'create'
293
+ ? await this.source.catalog.createSnapshot({ dataset_id: this.target.configuration.datasetId }, { ...options, idempotencyKey: `consumer-snapshot-${checkpoint.generation}` })
294
+ : await this.source.catalog.snapshot(checkpoint.snapshotId, options));
295
+ if (snapshot.dataset_id !== this.target.configuration.datasetId)
296
+ throw new Error('Snapshot dataset mismatch.');
297
+ if (snapshot.status === 'expired')
298
+ return result((await this.target.reset(state)) ? 'reset' : 'conflict');
299
+ if (snapshot.status === 'failed')
300
+ throw new Error('Catalog snapshot failed; inspect source health.');
301
+ sequence(snapshot.snapshot_sequence);
302
+ const next = {
303
+ ...checkpoint,
304
+ phase: snapshot.status === 'ready' ? 'snapshot' : 'waiting',
305
+ snapshotId: snapshot.id,
306
+ replayCursor: snapshot.replay_cursor,
307
+ lastSequence: snapshot.snapshot_sequence,
308
+ ...(snapshot.count === undefined ? {} : { expectedCount: snapshot.count }),
309
+ };
310
+ const saved = await this.target.commit(state, next, async () => { });
311
+ return result(saved ? (snapshot.status === 'ready' ? 'progress' : 'waiting') : 'conflict');
312
+ }
313
+ if (checkpoint.phase === 'snapshot') {
314
+ const page = operations.listSnapshotItems.output.parse(await this.source.catalog.snapshotItems(checkpoint.snapshotId, {
315
+ limit: this.pageSize,
316
+ ...(checkpoint.snapshotCursor ? { cursor: checkpoint.snapshotCursor } : {}),
317
+ }, options));
318
+ if (page.data.length > this.pageSize ||
319
+ (page.has_more &&
320
+ (!page.next_cursor ||
321
+ page.next_cursor === checkpoint.snapshotCursor ||
322
+ page.data.length === 0)))
323
+ throw new Error('Snapshot pagination did not advance within bounds.');
324
+ if (new Set(page.data.map((listing) => listing.id)).size !== page.data.length)
325
+ throw new Error('Snapshot page repeats a listing identity.');
326
+ const count = checkpoint.stagedCount + page.data.length;
327
+ if (!page.has_more &&
328
+ checkpoint.expectedCount !== undefined &&
329
+ count !== checkpoint.expectedCount)
330
+ throw new Error('Snapshot count did not match its declaration.');
331
+ const next = {
332
+ ...checkpoint,
333
+ stagedCount: count,
334
+ phase: page.has_more ? 'snapshot' : 'replay',
335
+ ...(page.has_more ? { snapshotCursor: page.next_cursor } : {}),
336
+ };
337
+ const saved = await this.target.commit(state, next, async (tx) => {
338
+ for (const listing of page.data)
339
+ await this.target.write(tx, checkpoint.generation, ListingSchema.parse(listing), listing.id, false, true);
340
+ });
341
+ return result(saved ? 'progress' : 'conflict', saved ? page.data.length : 0);
342
+ }
343
+ const page = operations.listCatalogChanges.output.parse(await this.source.catalog.changes({
344
+ dataset_id: this.target.configuration.datasetId,
345
+ cursor: checkpoint.replayCursor,
346
+ limit: this.pageSize,
347
+ }, options));
348
+ this.validateChanges(page, checkpoint);
349
+ const next = {
350
+ ...checkpoint,
351
+ replayCursor: page.next_cursor,
352
+ lastSequence: page.data.at(-1)?.sequence ?? checkpoint.lastSequence,
353
+ sourceFreshness: page.meta.source_freshness === undefined
354
+ ? []
355
+ : CatalogSourceFreshnessSchema.array().parse(page.meta.source_freshness),
356
+ };
357
+ const saved = await this.target.commit(state, next, async (tx) => {
358
+ for (const change of page.data)
359
+ await this.target.write(tx, checkpoint.generation, change.listing, change.listing_id, state.active_generation === checkpoint.generation);
360
+ // Catch up changes before revealing a new snapshot, so a property removed while the
361
+ // snapshot was downloading does not briefly reappear at bootstrap completion.
362
+ if (!page.has_more && state.active_generation !== checkpoint.generation)
363
+ await this.target.activate(tx, checkpoint.generation);
364
+ }, !page.has_more);
365
+ return result(saved ? (page.has_more || page.data.length ? 'progress' : 'idle') : 'conflict', saved ? page.data.length : 0);
366
+ }
367
+ catch (error) {
368
+ if (error instanceof ApiError && error.status === 410)
369
+ return result((await this.target.reset(state)) ? 'reset' : 'conflict');
370
+ if (error instanceof ApiError && [401, 403].includes(error.status)) {
371
+ await this.target.reset(state, true);
372
+ }
373
+ throw error;
374
+ }
375
+ }
376
+ validateChanges(page, checkpoint) {
377
+ if (!page.next_cursor ||
378
+ page.data.length > this.pageSize ||
379
+ (page.has_more && page.data.length === 0) ||
380
+ (page.data.length > 0 && page.next_cursor === checkpoint.replayCursor))
381
+ throw new Error('Change pagination did not advance within bounds.');
382
+ let previous = sequence(checkpoint.lastSequence);
383
+ for (const change of page.data) {
384
+ const current = sequence(change.sequence);
385
+ if (current <= previous)
386
+ throw new Error('Catalog changes must advance in publication order.');
387
+ if (change.operation === 'upsert' &&
388
+ (!change.listing ||
389
+ change.listing.id !== change.listing_id ||
390
+ change.listing.revision !== change.revision))
391
+ throw new Error('Invalid catalog upsert identity/revision.');
392
+ if (change.operation === 'remove' && change.listing !== null)
393
+ throw new Error('Catalog removal cannot contain a listing payload.');
394
+ previous = current;
395
+ }
396
+ }
397
+ async run(options) {
398
+ if (!Number.isInteger(options.maxSteps) || options.maxSteps < 1 || options.maxSteps > 1000)
399
+ throw new Error('Choose 1–1000 steps per invocation.');
400
+ const results = [];
401
+ for (let i = 0; i < options.maxSteps; i++) {
402
+ options.signal?.throwIfAborted();
403
+ const result = await this.step(options);
404
+ results.push(result);
405
+ if (['idle', 'waiting', 'blocked', 'conflict'].includes(result.status))
406
+ break;
407
+ }
408
+ return results;
409
+ }
410
+ }
@@ -0,0 +1,7 @@
1
+ import type { Listing } from './schemas.js';
2
+ /** Deterministic source vocabulary only. In particular, EV-ready is not an
3
+ * installed charger, boat parking is not RV parking, and silence is not false. */
4
+ export declare function sourceAmenities(listing: Listing): NonNullable<Listing['source_amenities']>;
5
+ /** Rebuild from the permitted record, including on historical privacy redaction.
6
+ * This is input text for search/embeddings, not generated marketing content. */
7
+ export declare function catalogSearchDocument(listing: Listing): NonNullable<Listing['search_document']>;
@@ -0,0 +1,78 @@
1
+ /** Deterministic source vocabulary only. In particular, EV-ready is not an
2
+ * installed charger, boat parking is not RV parking, and silence is not false. */
3
+ export function sourceAmenities(listing) {
4
+ const features = listing.property_details?.features ?? {};
5
+ const rules = {
6
+ hardwood: { fields: ['Flooring'], values: /^(hardwood|hard wood|solid hardwood)$/i },
7
+ solar: {
8
+ fields: [
9
+ 'GreenEnergyGeneration',
10
+ 'Roof',
11
+ 'Utilities',
12
+ 'ExteriorFeatures',
13
+ 'InteriorFeatures',
14
+ ],
15
+ values: /^(solar|solar panels|solar photovoltaic|solar electric|photovoltaics)$/i,
16
+ },
17
+ ev_charger: {
18
+ fields: ['ParkingFeatures', 'Utilities', 'GreenEnergyEfficient'],
19
+ values: /^(ev charger|electric vehicle charging station\(s\)|electric vehicle charging|electric vehicle charger|ev charging)$/i,
20
+ },
21
+ rv_parking: {
22
+ fields: ['ParkingFeatures'],
23
+ values: /^(rv parking|rv access\/parking|rv garage|recreational vehicle parking)$/i,
24
+ },
25
+ view: {
26
+ fields: ['View'],
27
+ values: /^(city|city lights|mountain\(s\)|mountain|ocean|water|lake|river|panoramic|park\/greenbelt|golf course|valley|trees\/woods)$/i,
28
+ },
29
+ };
30
+ return {
31
+ version: 'source-amenities-v1',
32
+ values: Object.fromEntries(Object.entries(rules).map(([key, rule]) => {
33
+ const positive = rule.fields.filter((field) => features[field]?.some((value) => rule.values.test(value.trim())));
34
+ const negative = rule.fields.filter((field) => features[field]?.some((value) => new RegExp(`^no ${key.replaceAll('_', ' ')}$`, 'i').test(value.trim()) ||
35
+ (key === 'view' && value.trim().toLowerCase() === 'none')));
36
+ const conflict = positive.length > 0 && negative.length > 0;
37
+ return [
38
+ key,
39
+ {
40
+ value: conflict ? null : positive.length ? true : negative.length ? false : null,
41
+ state: conflict
42
+ ? 'conflicting'
43
+ : positive.length || negative.length
44
+ ? 'supported'
45
+ : 'not_stated',
46
+ source_paths: [...new Set([...positive, ...negative])].map((field) => `property_details.features.${field}`),
47
+ },
48
+ ];
49
+ })),
50
+ };
51
+ }
52
+ /** Rebuild from the permitted record, including on historical privacy redaction.
53
+ * This is input text for search/embeddings, not generated marketing content. */
54
+ export function catalogSearchDocument(listing) {
55
+ const pieces = [
56
+ listing.property_details?.category,
57
+ listing.property_details?.sub_type,
58
+ listing.address.city,
59
+ listing.address.region,
60
+ listing.property_details?.subdivision,
61
+ listing.bedrooms === null ? null : `${listing.bedrooms} bedrooms`,
62
+ listing.bathrooms === null ? null : `${listing.bathrooms} bathrooms`,
63
+ listing.living_area !== null && listing.living_area_unit
64
+ ? `${listing.living_area} ${listing.living_area_unit}`
65
+ : null,
66
+ listing.year_built == null ? null : `built ${listing.year_built}`,
67
+ listing.price
68
+ ? `${listing.price.amount} ${listing.price.currency}${listing.price.period ? ` per ${listing.price.period}` : ''}`
69
+ : null,
70
+ ...Object.entries(listing.property_details?.source_flags ?? {}).map(([key, value]) => `${key}: ${value}`),
71
+ ...Object.entries(listing.property_details?.features ?? {}).map(([key, values]) => `${key}: ${values.join(', ')}`),
72
+ listing.description,
73
+ ];
74
+ return {
75
+ version: 'catalog-search-text-v1',
76
+ text: pieces.filter((value) => value != null && value !== '').join('. '),
77
+ };
78
+ }