thatcher 1.0.47 → 1.0.49
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 +15 -15
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/lib/automation-engine.js +113 -0
- package/src/lib/busybase/store.js +9 -5
- package/src/lib/crud-handlers.js +3 -3
- package/src/lib/field-types.js +45 -0
- package/src/server/server.js +42 -8
- package/src/services/permission.service.js +2 -2
- package/src/ui/board-view-renderer.js +76 -0
- package/src/ui/calendar-view-renderer.js +196 -0
- package/src/ui/grid-view-renderer.js +78 -0
- package/src/lib/compression.js +0 -44
- package/src/lib/route-resolver.js +0 -160
- package/src/lib/state-protocol.js +0 -184
- package/src/lib/state-transport-client.js +0 -178
- package/src/lib/state-transport-reconnect.js +0 -126
- package/src/lib/state-transport-server.js +0 -189
- package/src/lib/static-server.js +0 -97
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
import { getEntityLabel } from '@/config/spec-helpers.js';
|
|
4
|
+
|
|
5
|
+
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
6
|
+
const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
|
7
|
+
|
|
8
|
+
function calendarDateField(spec) {
|
|
9
|
+
if (spec.list?.dateField) return spec.list.dateField;
|
|
10
|
+
const entries = Object.entries(spec.fields || {});
|
|
11
|
+
const found = entries.find(([, f]) => f.type === 'date' || f.type === 'timestamp');
|
|
12
|
+
return found ? found[0] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toDateOrNull(value) {
|
|
16
|
+
if (value === null || value === undefined || value === '') return null;
|
|
17
|
+
const num = Number(value);
|
|
18
|
+
if (!isNaN(num) && num > 0) return new Date(num * 1000);
|
|
19
|
+
const d = new Date(value);
|
|
20
|
+
return isNaN(d) ? null : d;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dayKey(d) {
|
|
24
|
+
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calendarChip(entityName, record, titleField) {
|
|
28
|
+
const title = esc(record[titleField] ?? record.id);
|
|
29
|
+
return `<div class="calendar-chip" data-navigate="/${esc(entityName)}/${esc(record.id)}">${title}</div>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function calendarTitleField(spec) {
|
|
33
|
+
if (spec.list?.titleField) return spec.list.titleField;
|
|
34
|
+
const candidates = ['name', 'title', 'label'];
|
|
35
|
+
for (const c of candidates) if (spec.fields?.[c]) return c;
|
|
36
|
+
const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
|
|
37
|
+
return first || 'id';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderCalendarView(user, entityName, spec, records, options = {}) {
|
|
41
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
42
|
+
const dateField = calendarDateField(spec);
|
|
43
|
+
const titleField = calendarTitleField(spec);
|
|
44
|
+
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const year = Number(options.year) || now.getFullYear();
|
|
47
|
+
const month = options.month !== undefined ? Number(options.month) : now.getMonth();
|
|
48
|
+
|
|
49
|
+
const byDay = {};
|
|
50
|
+
const undated = [];
|
|
51
|
+
|
|
52
|
+
for (const r of records) {
|
|
53
|
+
const d = dateField ? toDateOrNull(r[dateField]) : null;
|
|
54
|
+
if (!d) { undated.push(r); continue; }
|
|
55
|
+
const key = dayKey(d);
|
|
56
|
+
if (!byDay[key]) byDay[key] = [];
|
|
57
|
+
byDay[key].push(r);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const firstOfMonth = new Date(year, month, 1);
|
|
61
|
+
const startWeekday = firstOfMonth.getDay();
|
|
62
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
63
|
+
|
|
64
|
+
const cells = [];
|
|
65
|
+
for (let i = 0; i < startWeekday; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
|
|
66
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
67
|
+
const key = `${year}-${month}-${day}`;
|
|
68
|
+
const dayRecords = byDay[key] || [];
|
|
69
|
+
const chips = dayRecords.map(r => calendarChip(entityName, r, titleField)).join('');
|
|
70
|
+
const isToday = year === now.getFullYear() && month === now.getMonth() && day === now.getDate();
|
|
71
|
+
cells.push(`<div class="calendar-cell${isToday ? ' calendar-cell-today' : ''}">
|
|
72
|
+
<div class="calendar-cell-date">${day}</div>
|
|
73
|
+
<div class="calendar-cell-chips">${chips}</div>
|
|
74
|
+
</div>`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const totalCells = startWeekday + daysInMonth;
|
|
78
|
+
const trailing = (7 - (totalCells % 7)) % 7;
|
|
79
|
+
for (let i = 0; i < trailing; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
|
|
80
|
+
|
|
81
|
+
const dayHeaders = DAY_NAMES.map(d => `<div class="calendar-day-header">${d}</div>`).join('');
|
|
82
|
+
|
|
83
|
+
let prevMonth = month - 1, prevYear = year;
|
|
84
|
+
if (prevMonth < 0) { prevMonth = 11; prevYear -= 1; }
|
|
85
|
+
let nextMonth = month + 1, nextYear = year;
|
|
86
|
+
if (nextMonth > 11) { nextMonth = 0; nextYear += 1; }
|
|
87
|
+
|
|
88
|
+
const undatedSection = undated.length
|
|
89
|
+
? `<div class="calendar-undated">
|
|
90
|
+
<h3>Undated (${undated.length})</h3>
|
|
91
|
+
<div class="calendar-undated-list">${undated.map(r => calendarChip(entityName, r, titleField)).join('')}</div>
|
|
92
|
+
</div>`
|
|
93
|
+
: '';
|
|
94
|
+
|
|
95
|
+
const noDateFieldNotice = !dateField
|
|
96
|
+
? `<div class="calendar-empty-state">This entity has no date field configured; all records are shown as undated.</div>`
|
|
97
|
+
: '';
|
|
98
|
+
|
|
99
|
+
const content = `<div class="page-header">
|
|
100
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
101
|
+
</div>
|
|
102
|
+
${noDateFieldNotice}
|
|
103
|
+
<div class="calendar-toolbar">
|
|
104
|
+
<a class="calendar-nav-link" href="?month=${prevMonth}&year=${prevYear}">« Prev</a>
|
|
105
|
+
<span class="calendar-month-label">${MONTH_NAMES[month]} ${year}</span>
|
|
106
|
+
<a class="calendar-nav-link" href="?month=${nextMonth}&year=${nextYear}">Next »</a>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="calendar-grid">
|
|
109
|
+
${dayHeaders}
|
|
110
|
+
${cells.join('')}
|
|
111
|
+
</div>
|
|
112
|
+
${undatedSection}`;
|
|
113
|
+
|
|
114
|
+
return page(user, `${label} | Calendar`, null, content);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function timelineFields(spec) {
|
|
118
|
+
if (spec.list?.timelineStart && spec.list?.timelineEnd) {
|
|
119
|
+
return [spec.list.timelineStart, spec.list.timelineEnd];
|
|
120
|
+
}
|
|
121
|
+
const fields = spec.fields || {};
|
|
122
|
+
const startCandidates = ['start_date', 'start'];
|
|
123
|
+
const endCandidates = ['end_date', 'due_date', 'end', 'deadline'];
|
|
124
|
+
const start = startCandidates.find(k => fields[k]) || null;
|
|
125
|
+
const end = endCandidates.find(k => fields[k]) || null;
|
|
126
|
+
return [start, end];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function timelineTitleField(spec) {
|
|
130
|
+
if (spec.list?.titleField) return spec.list.titleField;
|
|
131
|
+
const candidates = ['name', 'title', 'label'];
|
|
132
|
+
for (const c of candidates) if (spec.fields?.[c]) return c;
|
|
133
|
+
const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
|
|
134
|
+
return first || 'id';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderTimelineView(user, entityName, spec, records, options = {}) {
|
|
138
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
139
|
+
const [startField, endField] = timelineFields(spec);
|
|
140
|
+
const titleField = timelineTitleField(spec);
|
|
141
|
+
|
|
142
|
+
const plotted = [];
|
|
143
|
+
let missing = 0;
|
|
144
|
+
|
|
145
|
+
for (const r of records) {
|
|
146
|
+
const start = startField ? toDateOrNull(r[startField]) : null;
|
|
147
|
+
const end = endField ? toDateOrNull(r[endField]) : null;
|
|
148
|
+
if (!start || !end) { missing++; continue; }
|
|
149
|
+
plotted.push({ record: r, start, end });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!startField || !endField) {
|
|
153
|
+
return page(user, `${label} | Timeline`, null,
|
|
154
|
+
`<div class="page-header">
|
|
155
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="timeline-empty-state">This entity has no start/end date fields configured; timeline view requires <code>list.timelineStart</code>/<code>list.timelineEnd</code> or matching field names.</div>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const missingNotice = missing
|
|
161
|
+
? `<div class="timeline-missing-notice">${missing} item${missing === 1 ? '' : 's'} without dates not shown</div>`
|
|
162
|
+
: '';
|
|
163
|
+
|
|
164
|
+
if (!plotted.length) {
|
|
165
|
+
const content = `<div class="page-header">
|
|
166
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
167
|
+
</div>
|
|
168
|
+
${missingNotice}
|
|
169
|
+
<div class="timeline-empty-state">No items have both a start and end date.</div>`;
|
|
170
|
+
return page(user, `${label} | Timeline`, null, content);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const minTime = Math.min(...plotted.map(p => p.start.getTime()));
|
|
174
|
+
const maxTime = Math.max(...plotted.map(p => p.end.getTime()));
|
|
175
|
+
const span = Math.max(1, maxTime - minTime);
|
|
176
|
+
|
|
177
|
+
const rows = plotted.map(({ record, start, end }) => {
|
|
178
|
+
const title = esc(record[titleField] ?? record.id);
|
|
179
|
+
const offsetPct = ((start.getTime() - minTime) / span) * 100;
|
|
180
|
+
const widthPct = Math.max(1, ((end.getTime() - start.getTime()) / span) * 100);
|
|
181
|
+
return `<div class="timeline-row">
|
|
182
|
+
<div class="timeline-row-label">${title}</div>
|
|
183
|
+
<div class="timeline-row-track">
|
|
184
|
+
<div class="timeline-bar" data-navigate="/${esc(entityName)}/${esc(record.id)}" style="margin-left:${offsetPct}%;width:${widthPct}%"></div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>`;
|
|
187
|
+
}).join('');
|
|
188
|
+
|
|
189
|
+
const content = `<div class="page-header">
|
|
190
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
191
|
+
</div>
|
|
192
|
+
${missingNotice}
|
|
193
|
+
<div class="timeline-view">${rows}</div>`;
|
|
194
|
+
|
|
195
|
+
return page(user, `${label} | Timeline`, null, content);
|
|
196
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc, fmtVal, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
3
|
+
import { getDefaultSort, getAvailableFilters, getPageSize, getEntityLabel } from '@/config/spec-helpers.js';
|
|
4
|
+
|
|
5
|
+
const EMBEDDED_TYPES = new Set(['json', 'embedded']);
|
|
6
|
+
|
|
7
|
+
function getColumns(spec) {
|
|
8
|
+
const fields = spec?.fields || {};
|
|
9
|
+
const override = spec?.list?.columns;
|
|
10
|
+
if (Array.isArray(override) && override.length) {
|
|
11
|
+
return override
|
|
12
|
+
.filter(key => fields[key])
|
|
13
|
+
.map(key => [key, fields[key]]);
|
|
14
|
+
}
|
|
15
|
+
return Object.entries(fields).filter(([, f]) =>
|
|
16
|
+
!f.hidden && !EMBEDDED_TYPES.has(f.type)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isEditable(field) {
|
|
21
|
+
return field && field.readonly !== true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gridRow(entityName, item, columns) {
|
|
25
|
+
const cells = columns.map(([key, field]) => {
|
|
26
|
+
const value = item[key];
|
|
27
|
+
const rendered = fmtVal(value, key, item);
|
|
28
|
+
const editableAttr = isEditable(field) ? ` data-editable="${esc(key)}"` : '';
|
|
29
|
+
return `<td data-col="${esc(key)}"${editableAttr}>${rendered}</td>`;
|
|
30
|
+
}).join('');
|
|
31
|
+
return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${cells}</tr>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderGridView(user, entityName, spec, records, options = {}) {
|
|
35
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
36
|
+
const columns = getColumns(spec);
|
|
37
|
+
const defaultSort = getDefaultSort(spec);
|
|
38
|
+
const filters = getAvailableFilters(spec);
|
|
39
|
+
const pageSize = getPageSize(spec);
|
|
40
|
+
|
|
41
|
+
const headerCells = columns.map(([key, field]) => {
|
|
42
|
+
const colLabel = esc(field?.label || key);
|
|
43
|
+
const isDefaultSort = key === defaultSort.field;
|
|
44
|
+
return `<th data-sort="${esc(key)}" aria-label="Sort by ${colLabel}"${isDefaultSort ? ` class="sort-${esc(defaultSort.dir)}"` : ''}>${colLabel}</th>`;
|
|
45
|
+
}).join('');
|
|
46
|
+
|
|
47
|
+
const filterControls = filters.map(f => {
|
|
48
|
+
const fieldKey = typeof f === 'string' ? f : f.field;
|
|
49
|
+
const fieldSpec = spec.fields?.[fieldKey];
|
|
50
|
+
const filterLabel = esc(fieldSpec?.label || fieldKey);
|
|
51
|
+
const opts = (fieldSpec?.options || f.options || []).map(o => {
|
|
52
|
+
const value = typeof o === 'string' ? o : o.value;
|
|
53
|
+
const optLabel = typeof o === 'string' ? o : (o.label || o.value);
|
|
54
|
+
return `<option value="${esc(value)}">${esc(optLabel)}</option>`;
|
|
55
|
+
}).join('');
|
|
56
|
+
return `<div class="table-filter"><select data-filter="${esc(fieldKey)}" id="filter-${esc(fieldKey)}" aria-label="Filter by ${filterLabel}"><option value="">All ${filterLabel}</option>${opts}</select></div>`;
|
|
57
|
+
}).join('');
|
|
58
|
+
|
|
59
|
+
const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
|
|
60
|
+
emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
|
|
61
|
+
|
|
62
|
+
const content = `<div class="page-header">
|
|
63
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="table-wrap">
|
|
66
|
+
<div class="table-toolbar">
|
|
67
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
|
|
68
|
+
${filterControls}
|
|
69
|
+
<span class="table-count" id="row-count">${records.length} items</span>
|
|
70
|
+
</div>
|
|
71
|
+
<table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
|
|
72
|
+
<thead><tr>${headerCells}</tr></thead>
|
|
73
|
+
<tbody>${rows}</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</div>`;
|
|
76
|
+
|
|
77
|
+
return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT]);
|
|
78
|
+
}
|
package/src/lib/compression.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import zlib from 'zlib';
|
|
2
|
-
|
|
3
|
-
const COMPRESSION_THRESHOLD = 1024; // Only compress files >1KB
|
|
4
|
-
|
|
5
|
-
export function compress(content, acceptEncoding = '') {
|
|
6
|
-
const size = Buffer.byteLength(content, 'utf-8');
|
|
7
|
-
if (size < COMPRESSION_THRESHOLD) return { content, encoding: null };
|
|
8
|
-
|
|
9
|
-
const ae = acceptEncoding.toLowerCase();
|
|
10
|
-
|
|
11
|
-
if (ae.includes('br')) {
|
|
12
|
-
const compressed = zlib.brotliCompressSync(content, {
|
|
13
|
-
params: {
|
|
14
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: 6,
|
|
15
|
-
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
16
|
-
}
|
|
17
|
-
});
|
|
18
|
-
return { content: compressed, encoding: 'br' };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (ae.includes('gzip')) {
|
|
22
|
-
const compressed = zlib.gzipSync(content, { level: 6 });
|
|
23
|
-
return { content: compressed, encoding: 'gzip' };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return { content, encoding: null };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function getCacheHeaders(type, maxAge = 86400) {
|
|
30
|
-
if (type === 'static') {
|
|
31
|
-
return {
|
|
32
|
-
'Cache-Control': `public, max-age=${maxAge}, immutable`,
|
|
33
|
-
'Expires': new Date(Date.now() + maxAge * 1000).toUTCString()
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
if (type === 'dynamic') {
|
|
37
|
-
return {
|
|
38
|
-
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
39
|
-
'Pragma': 'no-cache',
|
|
40
|
-
'Expires': '0'
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
return {};
|
|
44
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
export function resolveParamRoute(baseDir, segments) {
|
|
5
|
-
if (!fs.existsSync(baseDir)) return null;
|
|
6
|
-
if (segments.length === 0) {
|
|
7
|
-
const r = path.join(baseDir, 'route.js');
|
|
8
|
-
return fs.existsSync(r) ? r : null;
|
|
9
|
-
}
|
|
10
|
-
const [seg, ...rest] = segments;
|
|
11
|
-
const entries = fs.readdirSync(baseDir, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
12
|
-
for (const entry of entries) {
|
|
13
|
-
const name = entry.name;
|
|
14
|
-
if (name === seg || name.startsWith('[')) {
|
|
15
|
-
const found = resolveParamRoute(path.join(baseDir, name), rest);
|
|
16
|
-
if (found) return found;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function resolveSpecificParams(routeFile, pathParts) {
|
|
23
|
-
const result = {};
|
|
24
|
-
const routeRelative = routeFile.replace(/.*src\/app\/api\//, '').replace(/\/route\.js$/, '');
|
|
25
|
-
const routeSegments = routeRelative.split('/');
|
|
26
|
-
const urlSegments = pathParts.slice(0);
|
|
27
|
-
for (let i = 0; i < routeSegments.length && i < urlSegments.length; i++) {
|
|
28
|
-
const seg = routeSegments[i];
|
|
29
|
-
if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
30
|
-
const paramName = seg.replace(/^\[\.\.\./, '').replace(/[[\]]/g, '');
|
|
31
|
-
result[paramName] = urlSegments[i];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return result;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function singularize(name) {
|
|
38
|
-
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
|
39
|
-
if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) return name.slice(0, -2);
|
|
40
|
-
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
|
|
41
|
-
return name;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildNestedRoutePath(baseDir, domain, parentEntity, childParts) {
|
|
45
|
-
if (childParts.length === 0) return null;
|
|
46
|
-
|
|
47
|
-
function buildSegments(parentParam, childParamFn) {
|
|
48
|
-
const segs = [domain, parentEntity, parentParam];
|
|
49
|
-
for (let i = 0; i < childParts.length; i++) {
|
|
50
|
-
if (i % 2 === 0) segs.push(childParts[i]);
|
|
51
|
-
else segs.push(childParamFn(i));
|
|
52
|
-
}
|
|
53
|
-
return path.join(baseDir, 'src/app/api', ...segs, 'route.js');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const variants = [
|
|
57
|
-
() => buildSegments('[id]', (i) => `[${childParts[i - 1]}Id]`),
|
|
58
|
-
() => buildSegments('[id]', (i) => `[${singularize(childParts[i - 1])}Id]`),
|
|
59
|
-
() => buildSegments(`[${parentEntity}Id]`, (i) => `[${childParts[i - 1]}Id]`),
|
|
60
|
-
() => buildSegments(`[${parentEntity}Id]`, (i) => `[${singularize(childParts[i - 1])}Id]`),
|
|
61
|
-
() => buildSegments('[id]', () => `[${childParts[0]}Id]`),
|
|
62
|
-
() => buildSegments('[id]', () => `[${singularize(childParts[0])}Id]`),
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
// Also try a "leaf-action" shape with no further id after the child segment.
|
|
66
|
-
// Routes like /mwr/review/[id]/export-pdf/route.js (parent + id + leaf action)
|
|
67
|
-
// were missed by the segment-pair variants above.
|
|
68
|
-
if (childParts.length === 1) {
|
|
69
|
-
const leaf = path.join(baseDir, 'src/app/api', domain, parentEntity, '[id]', childParts[0], 'route.js');
|
|
70
|
-
if (fs.existsSync(leaf)) return leaf;
|
|
71
|
-
const leafByEntity = path.join(baseDir, 'src/app/api', domain, parentEntity, `[${parentEntity}Id]`, childParts[0], 'route.js');
|
|
72
|
-
if (fs.existsSync(leafByEntity)) return leafByEntity;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
for (const variant of variants) {
|
|
76
|
-
const candidate = variant();
|
|
77
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
78
|
-
}
|
|
79
|
-
return variants[0]();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const DOMAINS = ['friday', 'mwr'];
|
|
83
|
-
|
|
84
|
-
export function resolveRoute(__dirname, pathname, url) {
|
|
85
|
-
const pathParts = pathname.slice(5).split('/').filter(Boolean);
|
|
86
|
-
|
|
87
|
-
// Reject any '..' or '.' segment before it can reach a filesystem path
|
|
88
|
-
// join or a dynamic import -- otherwise a crafted URL can traverse out of
|
|
89
|
-
// src/app/api into arbitrary files on disk.
|
|
90
|
-
if (pathParts.some(seg => seg === '..' || seg === '.')) {
|
|
91
|
-
return { routeFile: null, params: {}, isDomain: false, firstPart: pathParts[0], pathParts };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const firstPart = pathParts[0];
|
|
95
|
-
const isDomain = DOMAINS.includes(firstPart);
|
|
96
|
-
let routeFile = null;
|
|
97
|
-
let params = {};
|
|
98
|
-
|
|
99
|
-
if (isDomain) {
|
|
100
|
-
const domain = firstPart;
|
|
101
|
-
const domainParts = pathParts.slice(1);
|
|
102
|
-
|
|
103
|
-
const specificCheck = path.join(__dirname, `src/app/api/${domain}/${domainParts.join('/')}/route.js`);
|
|
104
|
-
if (fs.existsSync(specificCheck)) {
|
|
105
|
-
routeFile = specificCheck;
|
|
106
|
-
params = resolveSpecificParams(specificCheck, pathParts);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!routeFile && domainParts.length >= 3) {
|
|
110
|
-
const parentEntity = domainParts[0];
|
|
111
|
-
const childParts = domainParts.slice(2);
|
|
112
|
-
const parentId = domainParts[1];
|
|
113
|
-
const childEntity = childParts[0];
|
|
114
|
-
const childId = childParts[1] || null;
|
|
115
|
-
|
|
116
|
-
const nestedSpecific = buildNestedRoutePath(__dirname, domain, parentEntity, childParts);
|
|
117
|
-
if (nestedSpecific && fs.existsSync(nestedSpecific)) {
|
|
118
|
-
routeFile = nestedSpecific;
|
|
119
|
-
params = resolveSpecificParams(nestedSpecific, pathParts);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!routeFile) {
|
|
123
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
124
|
-
url.searchParams.set('domain', domain);
|
|
125
|
-
params = { entity: childEntity, path: childId ? [childId] : [], parentEntity, parentId };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!routeFile && domainParts.length >= 1) {
|
|
130
|
-
const entity = domainParts[0];
|
|
131
|
-
const entityPath = domainParts.slice(1);
|
|
132
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
133
|
-
url.searchParams.set('domain', domain);
|
|
134
|
-
params = { entity, path: entityPath };
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!routeFile && firstPart) {
|
|
139
|
-
const exactRoute = path.join(__dirname, `src/app/api/${pathParts.join('/')}/route.js`);
|
|
140
|
-
if (fs.existsSync(exactRoute)) {
|
|
141
|
-
routeFile = exactRoute;
|
|
142
|
-
params = resolveSpecificParams(exactRoute, pathParts);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (!routeFile && firstPart) {
|
|
147
|
-
const paramRouteFile = resolveParamRoute(path.join(__dirname, 'src/app/api', firstPart), pathParts.slice(1));
|
|
148
|
-
if (paramRouteFile) {
|
|
149
|
-
routeFile = paramRouteFile;
|
|
150
|
-
params = resolveSpecificParams(paramRouteFile, pathParts);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (!routeFile) {
|
|
155
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
156
|
-
params = { entity: firstPart, path: pathParts.slice(1) };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return { routeFile, params, isDomain, firstPart, pathParts };
|
|
160
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// state-sync channel: message-type vocabulary + VectorClock + schema validation
|
|
2
|
-
// shared by state-transport-server.js / state-transport-client.js /
|
|
3
|
-
// state-transport-reconnect.js. This is a structured, reconnect-aware
|
|
4
|
-
// WebSocket protocol (real `ws` transport, IP/rate-limited via
|
|
5
|
-
// connection-guard.js, exponential-backoff client reconnect with a polling
|
|
6
|
-
// fallback) -- distinct from the simpler in-process pub/sub in
|
|
7
|
-
// realtime-server.js (the "ws-broadcast" channel actually wired into the CRUD
|
|
8
|
-
// write path via src/lib/api.js). As of this writing, grepping the whole repo
|
|
9
|
-
// found ZERO importers of this quartet outside their own internal cross-imports
|
|
10
|
-
// (state-transport-client.js imports state-transport-reconnect.js; both import
|
|
11
|
-
// this file) -- nothing external constructs a StateTransportServer/Client, so
|
|
12
|
-
// this stack is currently unwired/dormant rather than superseding
|
|
13
|
-
// realtime-server.js. Keep both; re-check importers before deleting either.
|
|
14
|
-
const MESSAGE_TYPES = {
|
|
15
|
-
STATE_UPDATE: 'state_update',
|
|
16
|
-
STATE_SNAPSHOT: 'state_snapshot',
|
|
17
|
-
STATE_REQUEST: 'state_request',
|
|
18
|
-
STATE_ACK: 'state_ack',
|
|
19
|
-
STATE_NACK: 'state_nack',
|
|
20
|
-
PING: 'ping',
|
|
21
|
-
PONG: 'pong'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const ERROR_CODES = {
|
|
25
|
-
INVALID_MESSAGE: 'invalid_message',
|
|
26
|
-
INVALID_VERSION: 'invalid_version',
|
|
27
|
-
CONFLICT_DETECTED: 'conflict_detected',
|
|
28
|
-
RATE_LIMITED: 'rate_limited',
|
|
29
|
-
VALIDATION_FAILED: 'validation_failed'
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
class VectorClock {
|
|
33
|
-
constructor(nodeId) {
|
|
34
|
-
this.nodeId = nodeId
|
|
35
|
-
this.clock = { [nodeId]: 0 }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
increment() {
|
|
39
|
-
this.clock[this.nodeId] = (this.clock[this.nodeId] || 0) + 1
|
|
40
|
-
return this.clock[this.nodeId]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
update(otherClock) {
|
|
44
|
-
for (const [nodeId, timestamp] of Object.entries(otherClock)) {
|
|
45
|
-
this.clock[nodeId] = Math.max(this.clock[nodeId] || 0, timestamp)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
compare(otherClock) {
|
|
50
|
-
const keys = new Set([...Object.keys(this.clock), ...Object.keys(otherClock)])
|
|
51
|
-
let hasGreater = false
|
|
52
|
-
let hasLess = false
|
|
53
|
-
|
|
54
|
-
for (const key of keys) {
|
|
55
|
-
const mine = this.clock[key] || 0
|
|
56
|
-
const theirs = otherClock[key] || 0
|
|
57
|
-
if (mine > theirs) hasGreater = true
|
|
58
|
-
if (mine < theirs) hasLess = true
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (hasGreater && !hasLess) return 1
|
|
62
|
-
if (hasLess && !hasGreater) return -1
|
|
63
|
-
if (!hasGreater && !hasLess) return 0
|
|
64
|
-
return null // Concurrent
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
serialize() {
|
|
68
|
-
return { ...this.clock }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
static deserialize(data, nodeId) {
|
|
72
|
-
const vc = new VectorClock(nodeId)
|
|
73
|
-
vc.clock = { ...data }
|
|
74
|
-
return vc
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const MessageSchema = {
|
|
79
|
-
validate(message) {
|
|
80
|
-
if (!message || typeof message !== 'object') {
|
|
81
|
-
return { valid: false, error: 'Message must be an object' }
|
|
82
|
-
}
|
|
83
|
-
const validTypes = Object.values(MESSAGE_TYPES)
|
|
84
|
-
if (!validTypes.includes(message.type)) {
|
|
85
|
-
return { valid: false, error: 'Invalid message type' }
|
|
86
|
-
}
|
|
87
|
-
if (message.type !== MESSAGE_TYPES.PING && message.type !== MESSAGE_TYPES.PONG) {
|
|
88
|
-
if (!message.version || typeof message.version !== 'object') {
|
|
89
|
-
return { valid: false, error: 'Missing or invalid version vector' }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (message.type === MESSAGE_TYPES.STATE_UPDATE || message.type === MESSAGE_TYPES.STATE_SNAPSHOT) {
|
|
93
|
-
if (message.data === undefined) {
|
|
94
|
-
return { valid: false, error: 'Missing data field' }
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return { valid: true }
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function createMessage(type, payload = {}) {
|
|
102
|
-
return {
|
|
103
|
-
type,
|
|
104
|
-
timestamp: Date.now(),
|
|
105
|
-
...payload
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function createStateUpdate(vectorClock, data, operations = []) {
|
|
110
|
-
return createMessage(MESSAGE_TYPES.STATE_UPDATE, {
|
|
111
|
-
version: vectorClock.serialize(),
|
|
112
|
-
data,
|
|
113
|
-
operations
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function createStateSnapshot(vectorClock, data) {
|
|
118
|
-
return createMessage(MESSAGE_TYPES.STATE_SNAPSHOT, {
|
|
119
|
-
version: vectorClock.serialize(),
|
|
120
|
-
data
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function createStateRequest(vectorClock) {
|
|
125
|
-
return createMessage(MESSAGE_TYPES.STATE_REQUEST, {
|
|
126
|
-
version: vectorClock.serialize()
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function createStateAck(messageId, vectorClock) {
|
|
131
|
-
return createMessage(MESSAGE_TYPES.STATE_ACK, {
|
|
132
|
-
messageId,
|
|
133
|
-
version: vectorClock.serialize()
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function createStateNack(messageId, error, errorCode) {
|
|
138
|
-
return createMessage(MESSAGE_TYPES.STATE_NACK, {
|
|
139
|
-
messageId,
|
|
140
|
-
error,
|
|
141
|
-
errorCode
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function createPing() {
|
|
146
|
-
return createMessage(MESSAGE_TYPES.PING)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function createPong(pingTimestamp) {
|
|
150
|
-
return createMessage(MESSAGE_TYPES.PONG, { pingTimestamp })
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const Protocol = {
|
|
154
|
-
MESSAGE_TYPES,
|
|
155
|
-
ERROR_CODES,
|
|
156
|
-
VectorClock,
|
|
157
|
-
MessageSchema,
|
|
158
|
-
createMessage,
|
|
159
|
-
createStateUpdate,
|
|
160
|
-
createStateSnapshot,
|
|
161
|
-
createStateRequest,
|
|
162
|
-
createStateAck,
|
|
163
|
-
createStateNack,
|
|
164
|
-
createPing,
|
|
165
|
-
createPong
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export {
|
|
169
|
-
MESSAGE_TYPES,
|
|
170
|
-
ERROR_CODES,
|
|
171
|
-
VectorClock,
|
|
172
|
-
MessageSchema,
|
|
173
|
-
createMessage,
|
|
174
|
-
createStateUpdate,
|
|
175
|
-
createStateSnapshot,
|
|
176
|
-
createStateRequest,
|
|
177
|
-
createStateAck,
|
|
178
|
-
createStateNack,
|
|
179
|
-
createPing,
|
|
180
|
-
createPong,
|
|
181
|
-
Protocol
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export default Protocol
|