@genesislcap/mock-server 15.59.0 → 15.61.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/README.md +287 -40
- package/dist/db/criteria.d.ts +28 -9
- package/dist/db/criteria.js +856 -258
- package/dist/db/criteriaDialect.d.ts +6 -0
- package/dist/db/criteriaDialect.js +441 -0
- package/dist/db/criteriaTokens.d.ts +12 -0
- package/dist/db/criteriaTokens.js +53 -0
- package/dist/db/groovySyntax.d.ts +26 -0
- package/dist/db/groovySyntax.js +481 -0
- package/dist/db/groovyValidator.d.ts +11 -0
- package/dist/db/groovyValidator.js +523 -0
- package/dist/db/gsfExpr.d.ts +21 -0
- package/dist/db/gsfExpr.js +333 -0
- package/dist/db/identity.d.ts +2 -1
- package/dist/db/identity.js +24 -3
- package/dist/db/legacyCriteria.d.ts +7 -0
- package/dist/db/legacyCriteria.js +280 -0
- package/dist/db/ordering.d.ts +2 -0
- package/dist/db/ordering.js +24 -3
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +34 -3
- package/dist/db/store.d.ts +7 -0
- package/dist/db/store.js +55 -7
- package/dist/db/values.d.ts +19 -0
- package/dist/db/values.js +243 -0
- package/dist/handlers/commitEvent.js +3 -2
- package/dist/handlers/dataLogon.d.ts +2 -1
- package/dist/handlers/dataLogon.js +108 -51
- package/dist/handlers/eventValidation.d.ts +2 -1
- package/dist/handlers/eventValidation.js +33 -6
- package/dist/handlers/requestReply.js +43 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/connection.d.ts +1 -1
- package/dist/server.js +15 -8
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +979 -290
- package/src/db/criteriaDialect.ts +487 -0
- package/src/db/criteriaTokens.ts +72 -0
- package/src/db/groovySyntax.ts +500 -0
- package/src/db/groovyValidator.ts +546 -0
- package/src/db/gsfExpr.ts +392 -0
- package/src/db/identity.ts +22 -4
- package/src/db/legacyCriteria.ts +301 -0
- package/src/db/ordering.ts +27 -3
- package/src/db/schema.ts +43 -8
- package/src/db/store.ts +65 -7
- package/src/db/values.ts +247 -0
- package/src/handlers/commitEvent.ts +3 -2
- package/src/handlers/dataLogon.ts +134 -56
- package/src/handlers/eventValidation.ts +40 -13
- package/src/handlers/requestReply.ts +47 -14
- package/src/index.ts +6 -1
- package/src/protocol/connection.ts +3 -4
- package/src/server.ts +16 -15
- package/src/types.ts +7 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// What GSF's criteria functions compute (genesis-commons v8.15.29: Expr.java,
|
|
2
|
+
// TimeUtils.java, ExtraOperators.kt, TimeRange.kt, TimeContext.kt), for the
|
|
3
|
+
// evaluator in criteria.ts. GSF runs criteria in UTC (Joda's default zone,
|
|
4
|
+
// set by RxDb.kt), so every date here is a UTC one.
|
|
5
|
+
//
|
|
6
|
+
// A Java exception inside a compiled criteria (a date that doesn't parse, a
|
|
7
|
+
// null field, an unknown TimeRange) makes the row not match, however the
|
|
8
|
+
// expression goes on: `!Expr.dateIsEqual(F, 'bad')` matches nothing. These
|
|
9
|
+
// functions throw CriteriaEvaluationError for those, and the evaluator's
|
|
10
|
+
// predicate answers false.
|
|
11
|
+
|
|
12
|
+
export class CriteriaEvaluationError extends Error {
|
|
13
|
+
override name = 'CriteriaEvaluationError';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const evaluationError = (message: string): never => {
|
|
17
|
+
throw new CriteriaEvaluationError(message);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const HOURS_PER_DAY = 24;
|
|
21
|
+
const MINUTES_PER_HOUR = 60;
|
|
22
|
+
const SECONDS_PER_MINUTE = 60;
|
|
23
|
+
const MS_PER_SECOND = 1000;
|
|
24
|
+
const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
25
|
+
const MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE;
|
|
26
|
+
export const MS_PER_DAY = HOURS_PER_DAY * MS_PER_HOUR;
|
|
27
|
+
const DAYS_PER_WEEK = 7;
|
|
28
|
+
const MONTHS_PER_YEAR = 12;
|
|
29
|
+
|
|
30
|
+
// --- the calendar (proleptic Gregorian, as Joda's ISO chronology) ------------
|
|
31
|
+
|
|
32
|
+
const DAYS_PER_ERA = 146_097;
|
|
33
|
+
const YEARS_PER_ERA = 400;
|
|
34
|
+
const MARCH_BASED_SHIFT = 3; // months before March belong to the year before
|
|
35
|
+
const DAYS_IN_FIVE_MONTHS = 153;
|
|
36
|
+
const FIVE_MONTHS = 5;
|
|
37
|
+
const DAYS_TO_1970 = 719_468;
|
|
38
|
+
const LEAP_EVERY = 4;
|
|
39
|
+
const CENTURY = 100;
|
|
40
|
+
const DAYS_PER_YEAR = 365;
|
|
41
|
+
const DAYS_PER_4_YEARS = 1460;
|
|
42
|
+
const DAYS_PER_100_YEARS = 36_524;
|
|
43
|
+
const DAYS_PER_400_YEARS_MINUS_ONE = 146_096;
|
|
44
|
+
const FEBRUARY = 2;
|
|
45
|
+
const LONG_FEBRUARY = 29;
|
|
46
|
+
const SHORT_FEBRUARY = 28;
|
|
47
|
+
const APRIL = 4;
|
|
48
|
+
const JUNE = 6;
|
|
49
|
+
const SEPTEMBER = 9;
|
|
50
|
+
const NOVEMBER = 11;
|
|
51
|
+
const THIRTY_DAY_MONTHS = new Set([APRIL, JUNE, SEPTEMBER, NOVEMBER]);
|
|
52
|
+
const SHORT_MONTH = 30;
|
|
53
|
+
const LONG_MONTH = 31;
|
|
54
|
+
|
|
55
|
+
// Days since 1970-01-01 of a date (month 1-12), any year.
|
|
56
|
+
export function daysFromCivil(year: number, month: number, day: number): number {
|
|
57
|
+
const y = month <= 2 ? year - 1 : year;
|
|
58
|
+
const era = Math.floor(y / YEARS_PER_ERA);
|
|
59
|
+
const yearOfEra = y - era * YEARS_PER_ERA;
|
|
60
|
+
const monthFromMarch = (month + MONTHS_PER_YEAR - MARCH_BASED_SHIFT) % MONTHS_PER_YEAR;
|
|
61
|
+
const dayOfYear = Math.floor((DAYS_IN_FIVE_MONTHS * monthFromMarch + 2) / FIVE_MONTHS) + day - 1;
|
|
62
|
+
const dayOfEra =
|
|
63
|
+
yearOfEra * DAYS_PER_YEAR +
|
|
64
|
+
Math.floor(yearOfEra / LEAP_EVERY) -
|
|
65
|
+
Math.floor(yearOfEra / CENTURY) +
|
|
66
|
+
dayOfYear;
|
|
67
|
+
return era * DAYS_PER_ERA + dayOfEra - DAYS_TO_1970;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The date of a day since 1970-01-01.
|
|
71
|
+
export function civilFromDays(days: number): { year: number; month: number; day: number } {
|
|
72
|
+
const z = days + DAYS_TO_1970;
|
|
73
|
+
const era = Math.floor(z / DAYS_PER_ERA);
|
|
74
|
+
const dayOfEra = z - era * DAYS_PER_ERA;
|
|
75
|
+
const yearOfEra = Math.floor(
|
|
76
|
+
(dayOfEra -
|
|
77
|
+
Math.floor(dayOfEra / DAYS_PER_4_YEARS) +
|
|
78
|
+
Math.floor(dayOfEra / DAYS_PER_100_YEARS) -
|
|
79
|
+
Math.floor(dayOfEra / DAYS_PER_400_YEARS_MINUS_ONE)) /
|
|
80
|
+
DAYS_PER_YEAR,
|
|
81
|
+
);
|
|
82
|
+
const dayOfYear =
|
|
83
|
+
dayOfEra -
|
|
84
|
+
(DAYS_PER_YEAR * yearOfEra +
|
|
85
|
+
Math.floor(yearOfEra / LEAP_EVERY) -
|
|
86
|
+
Math.floor(yearOfEra / CENTURY));
|
|
87
|
+
const monthFromMarch = Math.floor((FIVE_MONTHS * dayOfYear + 2) / DAYS_IN_FIVE_MONTHS);
|
|
88
|
+
const day = dayOfYear - Math.floor((DAYS_IN_FIVE_MONTHS * monthFromMarch + 2) / FIVE_MONTHS) + 1;
|
|
89
|
+
const month =
|
|
90
|
+
monthFromMarch < MONTHS_PER_YEAR - 2
|
|
91
|
+
? monthFromMarch + MARCH_BASED_SHIFT
|
|
92
|
+
: monthFromMarch - (MONTHS_PER_YEAR - MARCH_BASED_SHIFT);
|
|
93
|
+
const year = yearOfEra + era * YEARS_PER_ERA + (month <= 2 ? 1 : 0);
|
|
94
|
+
return { year, month, day };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isLeapYear = (year: number) =>
|
|
98
|
+
year % LEAP_EVERY === 0 && (year % CENTURY !== 0 || year % YEARS_PER_ERA === 0);
|
|
99
|
+
|
|
100
|
+
function daysInMonth(year: number, month: number): number {
|
|
101
|
+
if (month === FEBRUARY) return isLeapYear(year) ? LONG_FEBRUARY : SHORT_FEBRUARY;
|
|
102
|
+
return THIRTY_DAY_MONTHS.has(month) ? SHORT_MONTH : LONG_MONTH;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const epochDay = (millis: number) => Math.floor(millis / MS_PER_DAY);
|
|
106
|
+
|
|
107
|
+
// --- TimeUtils.ALLOWED_DATE_PARSER -------------------------------------------
|
|
108
|
+
|
|
109
|
+
// One field of a Joda pattern as its parser reads it: up to `max` digits
|
|
110
|
+
// (at least one, more are never backtracked), a year's optionally signed.
|
|
111
|
+
interface PatternField {
|
|
112
|
+
field: 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'fraction';
|
|
113
|
+
max: number;
|
|
114
|
+
}
|
|
115
|
+
type Pattern = Array<PatternField | string>;
|
|
116
|
+
|
|
117
|
+
// `yyyy` reads at most four digits when another number follows it, and nine
|
|
118
|
+
// otherwise (DateTimeFormat.parsePatternTo); MM, dd, HH, mm and ss two;
|
|
119
|
+
// SSS three.
|
|
120
|
+
const YEAR: PatternField = { field: 'year', max: 4 };
|
|
121
|
+
const LONG_YEAR: PatternField = { field: 'year', max: 9 };
|
|
122
|
+
const MONTH: PatternField = { field: 'month', max: 2 };
|
|
123
|
+
const DAY: PatternField = { field: 'day', max: 2 };
|
|
124
|
+
const HOUR: PatternField = { field: 'hour', max: 2 };
|
|
125
|
+
const MINUTE: PatternField = { field: 'minute', max: 2 };
|
|
126
|
+
const SECOND: PatternField = { field: 'second', max: 2 };
|
|
127
|
+
const FRACTION: PatternField = { field: 'fraction', max: 3 };
|
|
128
|
+
|
|
129
|
+
// ProcessConstants: DATE_FORMAT_FULL, _TO_SECONDS, _TO_MINUTES, _TO_DATE
|
|
130
|
+
// and DATE_FORMAT_ISO8601_TO_DATE, in ALLOWED_DATE_PARSER's order.
|
|
131
|
+
const TO_DATE: Pattern = [YEAR, MONTH, DAY];
|
|
132
|
+
const TO_MINUTES: Pattern = [...TO_DATE, '-', HOUR, ':', MINUTE];
|
|
133
|
+
const TO_SECONDS: Pattern = [...TO_MINUTES, ':', SECOND];
|
|
134
|
+
const FULL: Pattern = [...TO_SECONDS, '.', FRACTION];
|
|
135
|
+
const ISO8601_TO_DATE: Pattern = [LONG_YEAR, '-', MONTH, '-', DAY];
|
|
136
|
+
const ALLOWED_PATTERNS = [FULL, TO_SECONDS, TO_MINUTES, TO_DATE, ISO8601_TO_DATE];
|
|
137
|
+
|
|
138
|
+
interface DateFields {
|
|
139
|
+
year: number;
|
|
140
|
+
month: number;
|
|
141
|
+
day: number;
|
|
142
|
+
hour: number;
|
|
143
|
+
minute: number;
|
|
144
|
+
second: number;
|
|
145
|
+
millis: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const DIGIT = /\d/;
|
|
149
|
+
const FRACTION_DIGITS = 3;
|
|
150
|
+
|
|
151
|
+
// The fields of `text` when the pattern reads all of it, else undefined.
|
|
152
|
+
function readPattern(text: string, pattern: Pattern): DateFields | undefined {
|
|
153
|
+
const fields: DateFields = {
|
|
154
|
+
year: 0,
|
|
155
|
+
month: 1,
|
|
156
|
+
day: 1,
|
|
157
|
+
hour: 0,
|
|
158
|
+
minute: 0,
|
|
159
|
+
second: 0,
|
|
160
|
+
millis: 0,
|
|
161
|
+
};
|
|
162
|
+
let at = 0;
|
|
163
|
+
for (const part of pattern) {
|
|
164
|
+
if (typeof part === 'string') {
|
|
165
|
+
if (text[at] !== part) return undefined;
|
|
166
|
+
at += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let sign = 1;
|
|
170
|
+
if (part.field === 'year' && (text[at] === '-' || text[at] === '+')) {
|
|
171
|
+
if (!DIGIT.test(text[at + 1] ?? '')) return undefined;
|
|
172
|
+
sign = text[at] === '-' ? -1 : 1;
|
|
173
|
+
at += 1;
|
|
174
|
+
}
|
|
175
|
+
let digits = '';
|
|
176
|
+
while (digits.length < part.max && DIGIT.test(text[at] ?? '')) {
|
|
177
|
+
digits += text[at];
|
|
178
|
+
at += 1;
|
|
179
|
+
}
|
|
180
|
+
if (digits === '') return undefined;
|
|
181
|
+
if (part.field === 'fraction') {
|
|
182
|
+
fields.millis = Number(digits.padEnd(FRACTION_DIGITS, '0'));
|
|
183
|
+
} else {
|
|
184
|
+
fields[part.field] = sign * Number(digits);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return at === text.length ? fields : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Joda's ISO chronology years.
|
|
191
|
+
const MIN_YEAR = -292_275_054;
|
|
192
|
+
const MAX_YEAR = 292_278_993;
|
|
193
|
+
const MAX_HOUR = 23;
|
|
194
|
+
const MAX_MINUTE = 59;
|
|
195
|
+
const MAX_SECOND = 59;
|
|
196
|
+
|
|
197
|
+
// The fields' instant, or a Joda IllegalFieldValueException.
|
|
198
|
+
function instantOf(fields: DateFields, text: string): number {
|
|
199
|
+
const { year, month, day, hour, minute, second, millis } = fields;
|
|
200
|
+
const valid =
|
|
201
|
+
year >= MIN_YEAR &&
|
|
202
|
+
year <= MAX_YEAR &&
|
|
203
|
+
month >= 1 &&
|
|
204
|
+
month <= MONTHS_PER_YEAR &&
|
|
205
|
+
day >= 1 &&
|
|
206
|
+
day <= daysInMonth(year, month) &&
|
|
207
|
+
hour <= MAX_HOUR &&
|
|
208
|
+
minute <= MAX_MINUTE &&
|
|
209
|
+
second <= MAX_SECOND;
|
|
210
|
+
if (!valid) evaluationError(`Cannot parse "${text}": a field value is out of range`);
|
|
211
|
+
return (
|
|
212
|
+
daysFromCivil(year, month, day) * MS_PER_DAY +
|
|
213
|
+
hour * MS_PER_HOUR +
|
|
214
|
+
minute * MS_PER_MINUTE +
|
|
215
|
+
second * MS_PER_SECOND +
|
|
216
|
+
millis
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseWith(text: string, patterns: Pattern[]): number {
|
|
221
|
+
for (const pattern of patterns) {
|
|
222
|
+
const fields = readPattern(text, pattern);
|
|
223
|
+
if (fields) return instantOf(fields, text);
|
|
224
|
+
}
|
|
225
|
+
return evaluationError(`Invalid format: "${text}"`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Expr.stringDateTime / TimeUtils.parseDateTime: the instant a criteria date
|
|
229
|
+
// names — yyyyMMdd-HH:mm:ss.SSS, yyyyMMdd-HH:mm:ss, yyyyMMdd-HH:mm, yyyyMMdd
|
|
230
|
+
// or yyyy-MM-dd, in UTC. Joda reads fewer digits than the pattern shows
|
|
231
|
+
// ('2024-1-1', '20240101-9:5') and a fraction of a second ('.5' is 500 ms),
|
|
232
|
+
// nothing else: no 'T', no offset, no spaces.
|
|
233
|
+
export const stringDateTime = (text: string): number => parseWith(text, ALLOWED_PATTERNS);
|
|
234
|
+
|
|
235
|
+
// Expr.stringLocalDate / TimeUtils.parseLocalDate: the same formats, as a day
|
|
236
|
+
// (days since 1970-01-01).
|
|
237
|
+
export const stringLocalDate = (text: string): number => epochDay(stringDateTime(text));
|
|
238
|
+
|
|
239
|
+
// Expr.singlePatternLocalDate: the day a yyyyMMdd date names — the only form
|
|
240
|
+
// the Long and DateTime overloads of dateIs* take for the date compared to.
|
|
241
|
+
export const singlePatternLocalDate = (text: string): number =>
|
|
242
|
+
epochDay(parseWith(text, [TO_DATE]));
|
|
243
|
+
|
|
244
|
+
// DateTime.now().toLocalDate() in UTC.
|
|
245
|
+
export const today = () => epochDay(Date.now());
|
|
246
|
+
|
|
247
|
+
// --- strings -----------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// Character.isWhitespace: the Unicode space separators except the no-break
|
|
250
|
+
// ones, and the ASCII controls \t \n \v \f \r and \u001C-\u001F.
|
|
251
|
+
const JAVA_WHITESPACE = /^[\t\n\v\f\r\p{Zl}\p{Zp}\p{Zs}]$/u;
|
|
252
|
+
const NO_BREAK_SPACE = /^[\u00A0\u2007\u202F]$/u;
|
|
253
|
+
const FILE_SEPARATOR = 0x1c;
|
|
254
|
+
const UNIT_SEPARATOR = 0x1f;
|
|
255
|
+
|
|
256
|
+
const isJavaWhitespace = (char: string) => {
|
|
257
|
+
const code = char.charCodeAt(0);
|
|
258
|
+
if (code >= FILE_SEPARATOR && code <= UNIT_SEPARATOR) return true;
|
|
259
|
+
return JAVA_WHITESPACE.test(char) && !NO_BREAK_SPACE.test(char);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// String.isBlank.
|
|
263
|
+
export const isBlank = (text: string) => [...text].every(isJavaWhitespace);
|
|
264
|
+
|
|
265
|
+
const upper = (char: string) => {
|
|
266
|
+
const result = char.toUpperCase();
|
|
267
|
+
return result.length === 1 ? result : char;
|
|
268
|
+
};
|
|
269
|
+
const lower = (char: string) => {
|
|
270
|
+
const result = char.toLowerCase();
|
|
271
|
+
return result.length === 1 ? result : char;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// String.regionMatches(true, ...): char by char, equal as they are, upper
|
|
275
|
+
// cased, or upper then lower cased.
|
|
276
|
+
function regionMatchesIgnoreCase(
|
|
277
|
+
text: string,
|
|
278
|
+
offset: number,
|
|
279
|
+
other: string,
|
|
280
|
+
length: number,
|
|
281
|
+
): boolean {
|
|
282
|
+
for (let index = 0; index < length; index += 1) {
|
|
283
|
+
const a = text[offset + index];
|
|
284
|
+
const b = other[index];
|
|
285
|
+
if (a === b) continue;
|
|
286
|
+
const [ua, ub] = [upper(a), upper(b)];
|
|
287
|
+
if (ua === ub || lower(ua) === lower(ub)) continue;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Expr.containsIgnoreCase and the String?.containsIgnoreCase extension.
|
|
294
|
+
export function containsIgnoreCase(text: string | null, search: string | null): boolean {
|
|
295
|
+
if (text === null || search === null) return false;
|
|
296
|
+
if (search.length === 0) return true;
|
|
297
|
+
for (let at = text.length - search.length; at >= 0; at -= 1) {
|
|
298
|
+
if (regionMatchesIgnoreCase(text, at, search, search.length)) return true;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Expr.containsWordsStartingWithIgnoreCase and its String? extension: whether
|
|
304
|
+
// a word of `text` — split on the delimiter, ' ' by default — starts with
|
|
305
|
+
// `search`, ignoring case. A word is never shorter than the search.
|
|
306
|
+
export function containsWordsStartingWithIgnoreCase(
|
|
307
|
+
text: string | null,
|
|
308
|
+
search: string | null,
|
|
309
|
+
delimiter = ' ',
|
|
310
|
+
): boolean {
|
|
311
|
+
if (text === null || search === null) return false;
|
|
312
|
+
const length = search.length;
|
|
313
|
+
if (length === 0) return true;
|
|
314
|
+
let start = 0;
|
|
315
|
+
let end: number;
|
|
316
|
+
do {
|
|
317
|
+
end = text.indexOf(delimiter, start);
|
|
318
|
+
if (end === -1) end = text.length;
|
|
319
|
+
if (end - start >= length && regionMatchesIgnoreCase(text, start, search, length)) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
start = end + 1;
|
|
323
|
+
} while (end !== text.length);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Expr.dateTimeIsInRange --------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const TIME_CONTEXT_OFFSETS: Record<string, number> = { PREVIOUS: 1, CURRENT: 0 };
|
|
330
|
+
const SATURDAY = 6;
|
|
331
|
+
const SUNDAY = 7;
|
|
332
|
+
const THURSDAY_OFFSET = 3; // 1970-01-01 was a Thursday
|
|
333
|
+
|
|
334
|
+
// ISO day of week, Monday 1 to Sunday 7.
|
|
335
|
+
const dayOfWeek = (day: number) =>
|
|
336
|
+
((((day + THURSDAY_OFFSET) % DAYS_PER_WEEK) + DAYS_PER_WEEK) % DAYS_PER_WEEK) + 1;
|
|
337
|
+
|
|
338
|
+
const startOfMonth = (year: number, month: number) => {
|
|
339
|
+
const months = year * MONTHS_PER_YEAR + (month - 1);
|
|
340
|
+
const y = Math.floor(months / MONTHS_PER_YEAR);
|
|
341
|
+
return daysFromCivil(y, months - y * MONTHS_PER_YEAR + 1, 1) * MS_PER_DAY;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// TimeRange.getTimeBounds: [beginning, end) in epoch millis. An enum name
|
|
345
|
+
// TimeRange.valueOf / TimeContext.valueOf doesn't know (they are case
|
|
346
|
+
// sensitive) throws.
|
|
347
|
+
export function timeBounds(range: string, context: string, now: number): [number, number] {
|
|
348
|
+
const offset = Object.hasOwn(TIME_CONTEXT_OFFSETS, context)
|
|
349
|
+
? TIME_CONTEXT_OFFSETS[context]
|
|
350
|
+
: evaluationError(`No enum constant TimeContext.${context}`);
|
|
351
|
+
const day = epochDay(now);
|
|
352
|
+
const { year, month } = civilFromDays(day);
|
|
353
|
+
switch (range) {
|
|
354
|
+
case 'HOUR': {
|
|
355
|
+
const beginning = Math.floor(now / MS_PER_HOUR) * MS_PER_HOUR - offset * MS_PER_HOUR;
|
|
356
|
+
return [beginning, beginning + MS_PER_HOUR];
|
|
357
|
+
}
|
|
358
|
+
case 'DAY': {
|
|
359
|
+
const beginning = (day - offset) * MS_PER_DAY;
|
|
360
|
+
return [beginning, beginning + MS_PER_DAY];
|
|
361
|
+
}
|
|
362
|
+
case 'WEEK': {
|
|
363
|
+
const shifted = day - offset * DAYS_PER_WEEK;
|
|
364
|
+
const monday = shifted - (dayOfWeek(shifted) - 1);
|
|
365
|
+
return [monday * MS_PER_DAY, (monday + DAYS_PER_WEEK) * MS_PER_DAY];
|
|
366
|
+
}
|
|
367
|
+
case 'WEEK_DAY': {
|
|
368
|
+
const base = day - offset;
|
|
369
|
+
if (offset === 0) {
|
|
370
|
+
return dayOfWeek(base) >= SATURDAY ? [0, 0] : [base * MS_PER_DAY, (base + 1) * MS_PER_DAY];
|
|
371
|
+
}
|
|
372
|
+
const weekday = dayOfWeek(base);
|
|
373
|
+
const friday = weekday === SATURDAY ? base - 1 : weekday === SUNDAY ? base - 2 : base;
|
|
374
|
+
return [friday * MS_PER_DAY, (friday + 1) * MS_PER_DAY];
|
|
375
|
+
}
|
|
376
|
+
case 'MONTH':
|
|
377
|
+
return [startOfMonth(year, month - offset), startOfMonth(year, month - offset + 1)];
|
|
378
|
+
case 'YEAR':
|
|
379
|
+
return [
|
|
380
|
+
daysFromCivil(year - offset, 1, 1) * MS_PER_DAY,
|
|
381
|
+
daysFromCivil(year - offset + 1, 1, 1) * MS_PER_DAY,
|
|
382
|
+
];
|
|
383
|
+
default:
|
|
384
|
+
return evaluationError(`No enum constant TimeRange.${range}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// TimeRange.valueOf(range).isInTimeRange(dateTime, TimeContext.valueOf(context)).
|
|
389
|
+
export function dateTimeIsInRange(millis: number, range: string, context: string): boolean {
|
|
390
|
+
const [beginning, end] = timeBounds(range, context, Date.now());
|
|
391
|
+
return millis >= beginning && millis < end;
|
|
392
|
+
}
|
package/src/db/identity.ts
CHANGED
|
@@ -17,21 +17,39 @@ export const RECORD_ID = 'RECORD_ID';
|
|
|
17
17
|
export const TIMESTAMP = 'TIMESTAMP';
|
|
18
18
|
export const RECORD_FIELDS: readonly string[] = [RECORD_ID, TIMESTAMP];
|
|
19
19
|
|
|
20
|
+
const INTEGER_TEXT = /^-?\d+$/;
|
|
21
|
+
|
|
20
22
|
// Hands out RECORD_ID/TIMESTAMP values: the current epoch millis, or one more
|
|
21
23
|
// than the last value when that is not ahead of it (seeding a thousand rows
|
|
22
|
-
// runs the counter a second or so ahead of the clock).
|
|
24
|
+
// runs the counter a second or so ahead of the clock). Once a seed brings a
|
|
25
|
+
// real server's id above 2^53 (as a string, which JSON can't carry exactly as
|
|
26
|
+
// a number), later stamps continue from it as strings, so a new row is still
|
|
27
|
+
// the newest one.
|
|
23
28
|
export class RecordClock {
|
|
24
29
|
private last = 0;
|
|
30
|
+
private beyondSafe: bigint | undefined;
|
|
25
31
|
|
|
26
|
-
next(): number {
|
|
32
|
+
next(): number | string {
|
|
33
|
+
if (this.beyondSafe !== undefined) {
|
|
34
|
+
this.beyondSafe += 1n;
|
|
35
|
+
return String(this.beyondSafe);
|
|
36
|
+
}
|
|
27
37
|
this.last = Math.max(Date.now(), this.last + 1);
|
|
28
38
|
return this.last;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
// Keeps later stamps above a value a seed row brought with it.
|
|
32
42
|
observe(value: unknown): void {
|
|
33
|
-
if (typeof value === 'number' && Number.isSafeInteger(value)
|
|
34
|
-
this.last = value;
|
|
43
|
+
if (typeof value === 'number' && Number.isSafeInteger(value)) {
|
|
44
|
+
if (value > this.last) this.last = value;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== 'string' || !INTEGER_TEXT.test(value)) return;
|
|
48
|
+
const big = BigInt(value);
|
|
49
|
+
if (big <= BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
50
|
+
if (Number(big) > this.last) this.last = Number(big);
|
|
51
|
+
} else if (this.beyondSafe === undefined || big > this.beyondSafe) {
|
|
52
|
+
this.beyondSafe = big;
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
55
|
}
|