@phis/contracts 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/addon.d.ts +57 -6
- package/dist/addon.js +10 -0
- package/dist/capabilities.d.ts +54 -4
- package/dist/core.d.ts +23 -2
- package/dist/core.js +10 -0
- package/dist/search.d.ts +96 -0
- package/dist/search.js +66 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ and change at different moments.
|
|
|
22
22
|
@phis/contracts/http the request headers phis and the site UI must spell the same way
|
|
23
23
|
@phis/contracts/locale the locale this software is authored in
|
|
24
24
|
@phis/contracts/logging the structured log vocabulary a Site writes and phis reads back
|
|
25
|
+
@phis/contracts/search the documents a Site writes of its Pages and the hits phis answers a query with
|
|
25
26
|
@phis/contracts/server-capabilities the capability snapshot phis reports and the UI acts on
|
|
26
27
|
@phis/contracts/site-groups the membership levels of a Site group, as both ends count them
|
|
27
28
|
@phis/contracts/user-state the shapes a Module may keep a person's decisions in, and the key grammar
|
|
@@ -60,7 +61,14 @@ erased at compile time apart from a handful of constants.
|
|
|
60
61
|
What it covers:
|
|
61
62
|
|
|
62
63
|
- **Manifest** -- `PhisAddonManifestV1`: identity, version, required Core capabilities, the routes an
|
|
63
|
-
Add-on claims, the services it provides.
|
|
64
|
+
Add-on claims, the services it provides, the jobs it runs and the events it listens to or emits.
|
|
65
|
+
- **Jobs and events** -- a job carries a `PhisAddonJobSchedule` (`every` one of
|
|
66
|
+
`PHIS_ADDON_JOB_INTERVALS`, five minutes at the shortest, with `at` and `weekday` for the daily and
|
|
67
|
+
weekly steps) that Core keeps: each moment of a schedule is run by exactly one instance. An event an
|
|
68
|
+
Add-on emits is a `PhisAddonEmittedEventId`, its own id in front (`@scope/name:what.happened`),
|
|
69
|
+
declared under `emits` and sent through the `events` capability (`PhisEventsCapabilityV1`); a listener
|
|
70
|
+
receives a `PhisAddonEmittedEventSubject` with emitter, time, hop count, dedupe key and facts. Core's
|
|
71
|
+
own events keep their bare names. `PHIS_ADDON_EVENT_MAX_HOPS` bounds a chain of handlers emitting.
|
|
64
72
|
- **Runtime** -- `PhisAddonRuntimeV1`, `PhisAddonHandler`, `PhisAddonRequestContext`. The artifact
|
|
65
73
|
exports it under the name `phisAddon` (`PhisAddonRuntimeModuleV1`).
|
|
66
74
|
- **Schema** -- `PhisAddonSchemaDescriptor` and its column, index and constraint descriptors. Tables are
|
|
@@ -190,6 +198,18 @@ The structured log vocabulary: `PhiLogService` (`phis`, `ui`, `site`, `cli`), `P
|
|
|
190
198
|
to journald, and the log surface in phis parses them back, filtering by exactly these services and
|
|
191
199
|
fields. How a line is serialised is each writer's own concern.
|
|
192
200
|
|
|
201
|
+
## `/search`
|
|
202
|
+
|
|
203
|
+
Searching a Site's own Pages, split the only way it can be: the Site writes the documents, phis keeps and
|
|
204
|
+
searches them. A Page's words stand in Widget configs, in content phis stores without knowing which Page
|
|
205
|
+
shows it, and in documents fetched while the Page is drawn, so only the Site -- resolving a Page as a
|
|
206
|
+
visitor's request does -- sees the whole text. It sends one `PhiSearchIndexSet` per Area and language,
|
|
207
|
+
named by a `fingerprint` of what it was built from, so a set phis already holds is not written twice;
|
|
208
|
+
phis answers a query with `PhiSearchResponse`, each `PhiSearchHit` carrying its Page's path and a
|
|
209
|
+
snippet in pieces. `readPhiSearchQuery` and `tokenizePhiSearchQuery` are called on both ends, so a
|
|
210
|
+
query the Site calls too short is one phis would not have answered. Only the Public and App Areas are
|
|
211
|
+
indexed (`PHI_SEARCH_INDEXED_AREA_MASK`).
|
|
212
|
+
|
|
193
213
|
## `/server-capabilities`
|
|
194
214
|
|
|
195
215
|
The snapshot `/api/v1/site/capabilities` reports: per provider (Core or an Add-on), the capabilities it
|
package/dist/addon.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* back a constant, it is in the wrong package.
|
|
13
13
|
*/
|
|
14
14
|
import type { PhisAddonCapabilities, PhisAddonEventHandler, PhisAddonJobHandler, PhisSiteAccess } from "./capabilities.js";
|
|
15
|
-
import type { PhisAddonEventId, PhisCoreCapabilityId, PhisServiceKind } from "./core.js";
|
|
15
|
+
import type { PhisAddonEmittedEventId, PhisAddonEventId, PhisCoreCapabilityId, PhisServiceKind } from "./core.js";
|
|
16
16
|
import type { PhisAddonQueryCatalog } from "./queries.js";
|
|
17
17
|
import type { PhisAddonSchemaDescriptor } from "./schema.js";
|
|
18
18
|
export declare const PHIS_ADDON_MANIFEST_VERSION: 1;
|
|
@@ -101,17 +101,61 @@ export type PhisAddonServiceProviderDescriptor = {
|
|
|
101
101
|
factoryExport: string;
|
|
102
102
|
requiredCoreCapabilities: PhisCoreCapabilityId[];
|
|
103
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* The steps a job may run at. Every step divides the hour or the day, so the moments a schedule names
|
|
106
|
+
* fall on the clock -- `5m` is :00, :05, :10 -- and every instance of Core computes the same moment
|
|
107
|
+
* from the same clock without asking another.
|
|
108
|
+
*
|
|
109
|
+
* Nothing shorter than five minutes: a job that has to look more often than that is polling for an
|
|
110
|
+
* event, and an event is what it should be given. Jobs run per Site, so a short step multiplies by
|
|
111
|
+
* the Sites of a stage, and the services a job reads from have quotas of their own.
|
|
112
|
+
*/
|
|
113
|
+
export declare const PHIS_ADDON_JOB_INTERVALS: readonly ["5m", "15m", "30m", "hourly", "daily", "weekly"];
|
|
114
|
+
export type PhisAddonJobInterval = (typeof PHIS_ADDON_JOB_INTERVALS)[number];
|
|
115
|
+
/**
|
|
116
|
+
* When a job wants to run. Core keeps it: the schedule is a promise, not a note for the operator.
|
|
117
|
+
*
|
|
118
|
+
* A schedule is a series of moments, and each moment is claimed by exactly one instance of Core --
|
|
119
|
+
* the claim is a row whose key is the moment, so the database, not a leader, decides who runs. A
|
|
120
|
+
* moment nobody claimed while every instance was down is run once at the next start, the latest one
|
|
121
|
+
* only; a job does not receive a week of missed runs after a week of downtime. A job still running
|
|
122
|
+
* when its next moment comes has that moment recorded as skipped rather than run beside itself, which
|
|
123
|
+
* is why a handler must finish well inside its own step.
|
|
124
|
+
*/
|
|
125
|
+
export type PhisAddonJobSchedule = {
|
|
126
|
+
every: PhisAddonJobInterval;
|
|
127
|
+
/**
|
|
128
|
+
* The time of day, `HH:MM` in UTC, for `daily` and `weekly`; absent means `00:00`. Not for the shorter
|
|
129
|
+
* steps, which fall on the clock by themselves.
|
|
130
|
+
*/
|
|
131
|
+
at?: string;
|
|
132
|
+
/** The ISO weekday for `weekly`, 1 (Monday) to 7 (Sunday); absent means Monday. */
|
|
133
|
+
weekday?: number;
|
|
134
|
+
};
|
|
104
135
|
export type PhisAddonJobDescriptor = {
|
|
105
136
|
id: string;
|
|
106
137
|
handler: string;
|
|
107
138
|
/**
|
|
108
|
-
*
|
|
139
|
+
* When Core runs this job. Absent means it runs only when an operator starts it with `phis addon
|
|
140
|
+
* job`, which also runs a scheduled job out of turn.
|
|
109
141
|
*
|
|
110
|
-
*
|
|
111
|
-
* one intended run. The operator schedules `phis addon job run`, which is one runner by construction,
|
|
112
|
-
* and this field tells them what the Add-on had in mind.
|
|
142
|
+
* Optional like every field added after this manifest version was in the wild.
|
|
113
143
|
*/
|
|
114
|
-
|
|
144
|
+
schedule?: PhisAddonJobSchedule;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* One event this Add-on announces under its own name.
|
|
148
|
+
*
|
|
149
|
+
* Declared like a job or a route: the id, its emitter's own id in front, is part of the manifest and
|
|
150
|
+
* therefore of the digest, so what an installed Add-on can say to the others is readable at install.
|
|
151
|
+
* Emitting an undeclared event is refused. `audience` says who may listen: every Add-on of the Site
|
|
152
|
+
* that declares a handler for it, which is the default, because the operator installed each of them;
|
|
153
|
+
* or `self`, for an Add-on that uses the outbox to hand work to its own handler after the fact.
|
|
154
|
+
*/
|
|
155
|
+
export type PhisAddonEmittedEventDescriptor = {
|
|
156
|
+
id: PhisAddonEmittedEventId;
|
|
157
|
+
description?: string;
|
|
158
|
+
audience?: "stage" | "self";
|
|
115
159
|
};
|
|
116
160
|
/**
|
|
117
161
|
* One setting an operator may configure for this Add-on.
|
|
@@ -259,6 +303,13 @@ export type PhisAddonManifestV1 = {
|
|
|
259
303
|
* Add-on that declares them and links nothing is told nothing.
|
|
260
304
|
*/
|
|
261
305
|
eventHandlers?: PhisAddonEventHandlerDescriptor[];
|
|
306
|
+
/**
|
|
307
|
+
* The events this Add-on emits. Absent means none, and an emit of anything is refused.
|
|
308
|
+
*
|
|
309
|
+
* Optional like `eventHandlers`. Declaring an event is not delivering it: a row is written only for
|
|
310
|
+
* an Add-on of the Site that declares a handler for the id, and none at all where nobody listens.
|
|
311
|
+
*/
|
|
312
|
+
emits?: PhisAddonEmittedEventDescriptor[];
|
|
262
313
|
/**
|
|
263
314
|
* What an operator may configure. Absent means nothing is.
|
|
264
315
|
*
|
package/dist/addon.js
CHANGED
|
@@ -13,3 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const PHIS_ADDON_MANIFEST_VERSION = 1;
|
|
15
15
|
export const PHIS_ADDON_ABI_VERSION = 1;
|
|
16
|
+
/**
|
|
17
|
+
* The steps a job may run at. Every step divides the hour or the day, so the moments a schedule names
|
|
18
|
+
* fall on the clock -- `5m` is :00, :05, :10 -- and every instance of Core computes the same moment
|
|
19
|
+
* from the same clock without asking another.
|
|
20
|
+
*
|
|
21
|
+
* Nothing shorter than five minutes: a job that has to look more often than that is polling for an
|
|
22
|
+
* event, and an event is what it should be given. Jobs run per Site, so a short step multiplies by
|
|
23
|
+
* the Sites of a stage, and the services a job reads from have quotas of their own.
|
|
24
|
+
*/
|
|
25
|
+
export const PHIS_ADDON_JOB_INTERVALS = ["5m", "15m", "30m", "hourly", "daily", "weekly"];
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* at the point where it made the mistake.
|
|
13
13
|
*/
|
|
14
14
|
import type { PhisAssetsCapabilityV1 } from "./assets.js";
|
|
15
|
-
import type { PhisAddonThreadEventId } from "./core.js";
|
|
15
|
+
import type { PhisAddonEmittedEventId, PhisAddonThreadEventId } from "./core.js";
|
|
16
16
|
import type { PhisPaymentChangedEventFacts, PhisPaymentNotificationsCapabilityV1, PhisPaymentsCapabilityV1 } from "./payments.js";
|
|
17
17
|
import type { PhisDataCapabilityV1 } from "./queries.js";
|
|
18
18
|
import type { PhisResourceLinksCapabilityV1 } from "./resource-links.js";
|
|
@@ -109,6 +109,52 @@ export type PhisAddonCapabilities = {
|
|
|
109
109
|
paymentNotifications?: PhisPaymentNotificationsCapabilityV1;
|
|
110
110
|
/** Writing to a person about an occasion this Add-on declared; Core owns the frame and the sending. */
|
|
111
111
|
notifications?: PhisNotificationsCapabilityV1;
|
|
112
|
+
/** Announcing an event this Add-on declared under its own name; Core owns the outbox and the delivery. */
|
|
113
|
+
events?: PhisEventsCapabilityV1;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The events capability: `@phis/server/events:v1`.
|
|
117
|
+
*
|
|
118
|
+
* An Add-on says that something happened; Core writes one outbox row per Add-on of this Site that
|
|
119
|
+
* declares a handler for the event, and delivers them the way it delivers its own events -- after the
|
|
120
|
+
* fact, in order, retried until they arrive. Emitting is only a write to the outbox, so it is cheap and
|
|
121
|
+
* never waits for a listener.
|
|
122
|
+
*
|
|
123
|
+
* `facts` are values about what happened, as JSON, bounded in size, and never references into another
|
|
124
|
+
* Add-on's tables: a listener runs with authority over its own tables only, and a fact it cannot read
|
|
125
|
+
* is a fact it should have been handed. `dedupeKey` makes a repeated emit safe -- a second call with
|
|
126
|
+
* the same key writes nothing for a listener already owed the first.
|
|
127
|
+
*
|
|
128
|
+
* Refused: an event the manifest does not declare under `emits`, a call from an unverified hook, and
|
|
129
|
+
* an emit from a handler already at `PHIS_ADDON_EVENT_MAX_HOPS`. Answers how many listeners were
|
|
130
|
+
* written for; zero means nobody on this Site listens, which is not a failure.
|
|
131
|
+
*/
|
|
132
|
+
export type PhisEventsCapabilityV1 = {
|
|
133
|
+
emit(input: PhisAddonEmitEventInput): Promise<{
|
|
134
|
+
recipients: number;
|
|
135
|
+
}>;
|
|
136
|
+
};
|
|
137
|
+
export type PhisAddonEmitEventInput = {
|
|
138
|
+
event: PhisAddonEmittedEventId;
|
|
139
|
+
facts?: Readonly<Record<string, unknown>>;
|
|
140
|
+
/** Unique within the Site and the event; a second emit with the same key writes nothing new. */
|
|
141
|
+
dedupeKey?: string;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* What a listener is handed about an event another Add-on -- or the same one -- emitted.
|
|
145
|
+
*
|
|
146
|
+
* The emitter's id says who, `occurredAt` when (the moment the outbox row was written), `hop` how many
|
|
147
|
+
* handlers the chain has passed through so far, and `facts` what the emitter said. `dedupeKey` is the
|
|
148
|
+
* emitter's, where it gave one; the delivery's own key, stable across retries, is `deliveryId` on the
|
|
149
|
+
* context, as for every event.
|
|
150
|
+
*/
|
|
151
|
+
export type PhisAddonEmittedEventSubject = {
|
|
152
|
+
kind: "emitted";
|
|
153
|
+
emitter: string;
|
|
154
|
+
occurredAt: string;
|
|
155
|
+
hop: number;
|
|
156
|
+
dedupeKey: string | null;
|
|
157
|
+
facts: Readonly<Record<string, unknown>>;
|
|
112
158
|
};
|
|
113
159
|
/**
|
|
114
160
|
* The roles capability: `@phis/server/roles:v1`.
|
|
@@ -399,8 +445,9 @@ export type PhisSettingsCapabilityV1 = {
|
|
|
399
445
|
*
|
|
400
446
|
* No request, no headers, and above all no actor: a job is the Add-on acting on its own data, not a
|
|
401
447
|
* person acting through it. That is why the row-level ladder does not apply inside one, and why a job
|
|
402
|
-
* is
|
|
403
|
-
*
|
|
448
|
+
* is started only by Core's scheduler at the moments its `schedule` names, or by an operator with
|
|
449
|
+
* `phis addon job`, and never from a route -- an Add-on that could start its own job from a request
|
|
450
|
+
* would have built itself a way around the ladder.
|
|
404
451
|
*/
|
|
405
452
|
export type PhisAddonJobContext = {
|
|
406
453
|
addonId: string;
|
|
@@ -434,7 +481,10 @@ export type PhisAddonEventFacts = {
|
|
|
434
481
|
threadId: number;
|
|
435
482
|
messageId: number | null;
|
|
436
483
|
};
|
|
437
|
-
} | PhisPaymentChangedEventFacts
|
|
484
|
+
} | PhisPaymentChangedEventFacts | {
|
|
485
|
+
event: PhisAddonEmittedEventId;
|
|
486
|
+
subject: PhisAddonEmittedEventSubject;
|
|
487
|
+
};
|
|
438
488
|
export type PhisAddonEventContext = PhisAddonEventFacts & {
|
|
439
489
|
addonId: string;
|
|
440
490
|
/** This delivery, stable across retries, so a handler can recognise one it already settled. */
|
package/dist/core.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export declare const PHIS_CORE_CAPABILITIES: {
|
|
|
50
50
|
readonly paymentNotifications: "@phis/server/payment-notifications:v1";
|
|
51
51
|
/** Writing to a person about an occasion the Add-on declared, with Core owning frame and transport. */
|
|
52
52
|
readonly notifications: "@phis/server/notifications:v1";
|
|
53
|
+
/** Announcing an event this Add-on declared under its own name, for the Add-ons of this Site that listen. */
|
|
54
|
+
readonly events: "@phis/server/events:v1";
|
|
53
55
|
};
|
|
54
56
|
export type PhisCoreCapabilityId = (typeof PHIS_CORE_CAPABILITIES)[keyof typeof PHIS_CORE_CAPABILITIES];
|
|
55
57
|
/**
|
|
@@ -75,8 +77,27 @@ export declare const PHIS_ADDON_EVENTS: {
|
|
|
75
77
|
readonly paymentChanged: "payment.changed";
|
|
76
78
|
};
|
|
77
79
|
export type PhisAddonThreadEventId = Exclude<(typeof PHIS_ADDON_EVENTS)[keyof typeof PHIS_ADDON_EVENTS], "payment.changed">;
|
|
78
|
-
/**
|
|
79
|
-
export type
|
|
80
|
+
/** The events Core itself emits: the thread events and the Payment event. */
|
|
81
|
+
export type PhisCoreEventId = PhisAddonThreadEventId | PhisPaymentChangedEventFacts["event"];
|
|
82
|
+
/**
|
|
83
|
+
* An event an Add-on emits, named under the Add-on that emits it: `@scope/name:what.happened`.
|
|
84
|
+
*
|
|
85
|
+
* The emitter's own id is the first half, so two Add-ons cannot name the same event and a reader of
|
|
86
|
+
* a manifest sees who announces it without looking anything up. The second half is the Add-on's own
|
|
87
|
+
* word for it, `^[a-z][a-z0-9.-]*$`, the shape Core's own event names have. Core's names stay bare:
|
|
88
|
+
* a bare name is Core's, a prefixed one is an Add-on's, and the two can never be confused.
|
|
89
|
+
*/
|
|
90
|
+
export type PhisAddonEmittedEventId = `@${string}/${string}:${string}`;
|
|
91
|
+
/** Delivered v1 event vocabulary: Core's own events, and what an installed Add-on emits. */
|
|
92
|
+
export type PhisAddonEventId = PhisCoreEventId | PhisAddonEmittedEventId;
|
|
93
|
+
/**
|
|
94
|
+
* How many Add-ons an event may pass through.
|
|
95
|
+
*
|
|
96
|
+
* An Add-on that emits inside an event handler writes an event with the incoming hop plus one. A
|
|
97
|
+
* handler that answers every event with an event, met by another doing the same, would otherwise
|
|
98
|
+
* write forever; at this depth the emit is refused and logged instead of delivered.
|
|
99
|
+
*/
|
|
100
|
+
export declare const PHIS_ADDON_EVENT_MAX_HOPS = 4;
|
|
80
101
|
/**
|
|
81
102
|
* The Core-owned service kinds an Add-on may implement.
|
|
82
103
|
*
|
package/dist/core.js
CHANGED
|
@@ -49,6 +49,8 @@ export const PHIS_CORE_CAPABILITIES = {
|
|
|
49
49
|
paymentNotifications: "@phis/server/payment-notifications:v1",
|
|
50
50
|
/** Writing to a person about an occasion the Add-on declared, with Core owning frame and transport. */
|
|
51
51
|
notifications: "@phis/server/notifications:v1",
|
|
52
|
+
/** Announcing an event this Add-on declared under its own name, for the Add-ons of this Site that listen. */
|
|
53
|
+
events: "@phis/server/events:v1",
|
|
52
54
|
};
|
|
53
55
|
/**
|
|
54
56
|
* What Core tells an Add-on about after the fact.
|
|
@@ -72,6 +74,14 @@ export const PHIS_ADDON_EVENTS = {
|
|
|
72
74
|
threadLinkRemoved: "thread.link.removed",
|
|
73
75
|
paymentChanged: "payment.changed",
|
|
74
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* How many Add-ons an event may pass through.
|
|
79
|
+
*
|
|
80
|
+
* An Add-on that emits inside an event handler writes an event with the incoming hop plus one. A
|
|
81
|
+
* handler that answers every event with an event, met by another doing the same, would otherwise
|
|
82
|
+
* write forever; at this depth the emit is refused and logged instead of delivered.
|
|
83
|
+
*/
|
|
84
|
+
export const PHIS_ADDON_EVENT_MAX_HOPS = 4;
|
|
75
85
|
/**
|
|
76
86
|
* The Core-owned service kinds an Add-on may implement.
|
|
77
87
|
*
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Areas a search index covers: a visitor's Public Site and the App a signed-in member reaches.
|
|
3
|
+
*
|
|
4
|
+
* Admin, Editor, Builder and Accounting are the operator's chrome, not Pages anybody searches, and a
|
|
5
|
+
* document set declared for one of them is refused rather than kept.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PHI_SEARCH_INDEXED_AREA_MASK: number;
|
|
8
|
+
/** The URL parameter the search box writes and the results Widget reads. */
|
|
9
|
+
export declare const PHI_SEARCH_QUERY_PARAM = "q";
|
|
10
|
+
/** A query shorter than this is not a search; two letters match too much to be an answer. */
|
|
11
|
+
export declare const PHI_SEARCH_MIN_QUERY_LENGTH = 2;
|
|
12
|
+
export declare const PHI_SEARCH_MAX_QUERY_LENGTH = 200;
|
|
13
|
+
export declare const PHI_SEARCH_DEFAULT_LIMIT = 20;
|
|
14
|
+
export declare const PHI_SEARCH_MAX_LIMIT = 50;
|
|
15
|
+
/** How many documents one set may carry; a Site with more Pages than this is a different product. */
|
|
16
|
+
export declare const PHI_SEARCH_MAX_DOCUMENTS = 5000;
|
|
17
|
+
export declare const PHI_SEARCH_MAX_PATH_LENGTH = 512;
|
|
18
|
+
export declare const PHI_SEARCH_MAX_TITLE_LENGTH = 512;
|
|
19
|
+
export declare const PHI_SEARCH_MAX_DESCRIPTION_LENGTH = 2048;
|
|
20
|
+
/** The text of one Page, as indexed; the words beyond it are not searched. */
|
|
21
|
+
export declare const PHI_SEARCH_MAX_TEXT_LENGTH = 200000;
|
|
22
|
+
/** One Page in one language, as the Site read it when the set was built. */
|
|
23
|
+
export type PhiSearchDocument = {
|
|
24
|
+
/** The Page's address within its Area, `/docs/install`, as a link to it is written. */
|
|
25
|
+
path: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
/** The Page's words, in reading order, with no markup; a heading is a line like any other. */
|
|
29
|
+
text: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Every document of one Area in one language, replacing whatever the set held before.
|
|
33
|
+
*
|
|
34
|
+
* `fingerprint` names what the set was built from -- the live revisions, the route table, the Module
|
|
35
|
+
* addresses -- so phis can tell a set it already holds from a new one and several Site processes
|
|
36
|
+
* noticing the same publish write it once.
|
|
37
|
+
*/
|
|
38
|
+
export type PhiSearchIndexSet = {
|
|
39
|
+
areaMask: number;
|
|
40
|
+
locale: string;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
documents: PhiSearchDocument[];
|
|
43
|
+
};
|
|
44
|
+
/** What phis holds of one set: enough for a Site to decide whether to build it again. */
|
|
45
|
+
export type PhiSearchIndexState = {
|
|
46
|
+
areaMask: number;
|
|
47
|
+
locale: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
documentCount: number;
|
|
50
|
+
/** ISO instant. */
|
|
51
|
+
indexedAt: string;
|
|
52
|
+
};
|
|
53
|
+
export type PhiSearchIndexWriteResult = {
|
|
54
|
+
/** `false` when the set phis holds already carries this fingerprint, and nothing was written. */
|
|
55
|
+
accepted: boolean;
|
|
56
|
+
state: PhiSearchIndexState;
|
|
57
|
+
};
|
|
58
|
+
/** A piece of a snippet; a `match` piece is one of the query's words as it stands in the text. */
|
|
59
|
+
export type PhiSearchSnippetSegment = {
|
|
60
|
+
text: string;
|
|
61
|
+
match: boolean;
|
|
62
|
+
};
|
|
63
|
+
export type PhiSearchHit = {
|
|
64
|
+
path: string;
|
|
65
|
+
areaMask: number;
|
|
66
|
+
title: string;
|
|
67
|
+
description: string;
|
|
68
|
+
/** The passage around the best matches, in pieces; empty when the match is in the title alone. */
|
|
69
|
+
snippet: PhiSearchSnippetSegment[];
|
|
70
|
+
/** Higher is better; comparable within one answer only. */
|
|
71
|
+
rank: number;
|
|
72
|
+
};
|
|
73
|
+
export type PhiSearchResponse = {
|
|
74
|
+
/** The query as phis read it (`readPhiSearchQuery`). */
|
|
75
|
+
query: string;
|
|
76
|
+
locale: string;
|
|
77
|
+
/** The Areas that were searched, as a mask. */
|
|
78
|
+
areaMask: number;
|
|
79
|
+
hits: PhiSearchHit[];
|
|
80
|
+
/** How many documents match in all, of which `hits` are the first. */
|
|
81
|
+
total: number;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The query as both ends read it, or `null` for no query: whitespace collapsed, bounds applied.
|
|
85
|
+
*
|
|
86
|
+
* Length is measured after collapsing, so a visitor cannot pad past the ceiling with spaces, and a query
|
|
87
|
+
* over the ceiling is cut rather than refused -- the first two hundred characters are a search, the rest
|
|
88
|
+
* was never going to narrow it.
|
|
89
|
+
*/
|
|
90
|
+
export declare function readPhiSearchQuery(value: unknown): string | null;
|
|
91
|
+
/**
|
|
92
|
+
* The words of a query, as both ends count them: letters and digits of any script, lower-cased,
|
|
93
|
+
* everything else a separator. phis builds its text query from these; the Site marks them in what it
|
|
94
|
+
* shows. A word is at most 64 characters, so a pasted token does not become a search for nothing.
|
|
95
|
+
*/
|
|
96
|
+
export declare function tokenizePhiSearchQuery(query: string): string[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Site and phis say to each other about searching the Site's own Pages.
|
|
3
|
+
*
|
|
4
|
+
* The texts a visitor searches are not phis's to know: a Page's words stand in Widget configs, in content
|
|
5
|
+
* phis stores without knowing which Page shows it, and in documents a Widget fetches from elsewhere while
|
|
6
|
+
* the Page is drawn. Only the Site, resolving a Page the way a visitor's request resolves it, sees the
|
|
7
|
+
* whole text. So the Site writes the documents, one per Page and language, and phis keeps them, indexes
|
|
8
|
+
* them and answers a query with the Pages that match -- which is the half a database does well.
|
|
9
|
+
*
|
|
10
|
+
* Both halves read the same shapes: what the Site sends (`PhiSearchIndexSet`), what phis answers
|
|
11
|
+
* (`PhiSearchResponse`), and the query's admission (`readPhiSearchQuery`), because a query the Site shows
|
|
12
|
+
* as "too short" and phis would have answered -- or the reverse -- is a visitor told two things at once.
|
|
13
|
+
*/
|
|
14
|
+
import { PhiCmsVisibilityContext } from "./cms.js";
|
|
15
|
+
/**
|
|
16
|
+
* The Areas a search index covers: a visitor's Public Site and the App a signed-in member reaches.
|
|
17
|
+
*
|
|
18
|
+
* Admin, Editor, Builder and Accounting are the operator's chrome, not Pages anybody searches, and a
|
|
19
|
+
* document set declared for one of them is refused rather than kept.
|
|
20
|
+
*/
|
|
21
|
+
export const PHI_SEARCH_INDEXED_AREA_MASK = PhiCmsVisibilityContext.PublicArea | PhiCmsVisibilityContext.AppArea;
|
|
22
|
+
/** The URL parameter the search box writes and the results Widget reads. */
|
|
23
|
+
export const PHI_SEARCH_QUERY_PARAM = "q";
|
|
24
|
+
/** A query shorter than this is not a search; two letters match too much to be an answer. */
|
|
25
|
+
export const PHI_SEARCH_MIN_QUERY_LENGTH = 2;
|
|
26
|
+
export const PHI_SEARCH_MAX_QUERY_LENGTH = 200;
|
|
27
|
+
export const PHI_SEARCH_DEFAULT_LIMIT = 20;
|
|
28
|
+
export const PHI_SEARCH_MAX_LIMIT = 50;
|
|
29
|
+
/** How many documents one set may carry; a Site with more Pages than this is a different product. */
|
|
30
|
+
export const PHI_SEARCH_MAX_DOCUMENTS = 5000;
|
|
31
|
+
export const PHI_SEARCH_MAX_PATH_LENGTH = 512;
|
|
32
|
+
export const PHI_SEARCH_MAX_TITLE_LENGTH = 512;
|
|
33
|
+
export const PHI_SEARCH_MAX_DESCRIPTION_LENGTH = 2048;
|
|
34
|
+
/** The text of one Page, as indexed; the words beyond it are not searched. */
|
|
35
|
+
export const PHI_SEARCH_MAX_TEXT_LENGTH = 200_000;
|
|
36
|
+
/**
|
|
37
|
+
* The query as both ends read it, or `null` for no query: whitespace collapsed, bounds applied.
|
|
38
|
+
*
|
|
39
|
+
* Length is measured after collapsing, so a visitor cannot pad past the ceiling with spaces, and a query
|
|
40
|
+
* over the ceiling is cut rather than refused -- the first two hundred characters are a search, the rest
|
|
41
|
+
* was never going to narrow it.
|
|
42
|
+
*/
|
|
43
|
+
export function readPhiSearchQuery(value) {
|
|
44
|
+
if (typeof value !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
47
|
+
if (collapsed.length < PHI_SEARCH_MIN_QUERY_LENGTH)
|
|
48
|
+
return null;
|
|
49
|
+
return collapsed.length > PHI_SEARCH_MAX_QUERY_LENGTH
|
|
50
|
+
? collapsed.slice(0, PHI_SEARCH_MAX_QUERY_LENGTH).trimEnd()
|
|
51
|
+
: collapsed;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The words of a query, as both ends count them: letters and digits of any script, lower-cased,
|
|
55
|
+
* everything else a separator. phis builds its text query from these; the Site marks them in what it
|
|
56
|
+
* shows. A word is at most 64 characters, so a pasted token does not become a search for nothing.
|
|
57
|
+
*/
|
|
58
|
+
export function tokenizePhiSearchQuery(query) {
|
|
59
|
+
const words = [];
|
|
60
|
+
for (const match of query.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
|
|
61
|
+
const word = match[0].slice(0, 64);
|
|
62
|
+
if (!words.includes(word))
|
|
63
|
+
words.push(word);
|
|
64
|
+
}
|
|
65
|
+
return words;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phis/contracts",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
"import": "./dist/resource-links.js",
|
|
71
71
|
"default": "./dist/resource-links.js"
|
|
72
72
|
},
|
|
73
|
+
"./search": {
|
|
74
|
+
"types": "./dist/search.d.ts",
|
|
75
|
+
"import": "./dist/search.js",
|
|
76
|
+
"default": "./dist/search.js"
|
|
77
|
+
},
|
|
73
78
|
"./server-capabilities": {
|
|
74
79
|
"types": "./dist/server-capabilities.d.ts",
|
|
75
80
|
"import": "./dist/server-capabilities.js",
|