@assembly-js/app-foundation 0.11.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -19,6 +19,9 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.12.0
23
+ - [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
24
+
22
25
  ## 0.11.0
23
26
  - [behavior] `check:record-urls` now checks each surface on its own: a record opened on the client portal needs its own `/c` route or `useUrlState`, and an internal dynamic route no longer exempts it. It also catches a list/detail view switch held in `useState`, so a client's request page keeps its own URL that can be reloaded or shared.
24
27
  - [guidance] `assembly-app-inner-location` says the record URL rule applies to the client portal too, with `app/c/[id]/page.tsx` or `?id=` through `useUrlState` as the two valid shapes.
@@ -16,7 +16,8 @@
16
16
  // as warnings: those paths have no agent who can act on a failure, so failing
17
17
  // them would only strand an app that already worked. A below-floor schedule
18
18
  // the app shipped before the turn stays a warning even then (see
19
- // baselineCronSchedules).
19
+ // baselineCronSchedules), as does a cron route the app shipped with no schedule
20
+ // at all (see baselineUnscheduledRoutes).
20
21
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
21
22
  import { spawnSync } from 'node:child_process';
22
23
  import { join, relative, sep } from 'node:path';
@@ -42,7 +43,8 @@ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
42
43
  // Set by the turn program on a platform update turn, where the update itself is
43
44
  // the mandate to fix whatever is wrong, so findings fail the build whether or
44
45
  // not the turn touched the cron wiring.
45
- const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
46
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
47
+ const STRICT = process.argv.includes('--strict') || UPDATE_TURN;
46
48
  const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
47
49
 
48
50
  function report(lines, { fatal }) {
@@ -92,6 +94,44 @@ function baselineCronSchedules() {
92
94
  return schedules;
93
95
  }
94
96
 
97
+ // Cron route files (repo-relative, posix) that existed at the turn baseline with
98
+ // no vercel.json entry invoking them. An update turn has no mandate to schedule
99
+ // work the app never ran, so these only warn. An unreadable baseline yields an
100
+ // empty set, which keeps every unscheduled route fatal.
101
+ function baselineUnscheduledRoutes() {
102
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
103
+ const unscheduled = new Set();
104
+ if (!baseline || !UPDATE_TURN) return unscheduled;
105
+ const listing = spawnSync('git', ['ls-tree', '-r', '--name-only', baseline, '--', 'app'], {
106
+ cwd: ROOT,
107
+ encoding: 'utf8',
108
+ });
109
+ if (listing.status !== 0) return unscheduled;
110
+ // Route groups can hold cron routes too, so list all of app/ and filter by the URL served.
111
+ const routePaths = listing.stdout
112
+ .split('\n')
113
+ .filter((p) => ROUTE_FILE.test(p.split('/').pop()))
114
+ .filter((p) => ('/' + urlSegments(p.split('/').slice(1)).join('/') + '/').startsWith(CRON_ROUTE_PREFIX));
115
+ const shown = spawnSync('git', ['show', `${baseline}:vercel.json`], { cwd: ROOT, encoding: 'utf8' });
116
+ let baselineCrons = [];
117
+ if (shown.status === 0) {
118
+ try {
119
+ const parsed = JSON.parse(shown.stdout).crons;
120
+ if (Array.isArray(parsed)) baselineCrons = parsed;
121
+ } catch {
122
+ // An unparseable baseline file scheduled nothing.
123
+ }
124
+ }
125
+ const scheduledPaths = baselineCrons
126
+ .filter((c) => typeof c?.path === 'string')
127
+ .map((c) => c.path.replace(/\/+$/, '').split('/').filter(Boolean));
128
+ for (const path of routePaths) {
129
+ const segments = urlSegments(path.split('/').slice(1));
130
+ if (!scheduledPaths.some((pathSegments) => routeServes(segments, pathSegments))) unscheduled.add(path);
131
+ }
132
+ return unscheduled;
133
+ }
134
+
95
135
  function cronKey(entry) {
96
136
  return `${entry?.path}\n${typeof entry?.schedule === 'string' ? entry.schedule.trim() : ''}`;
97
137
  }
@@ -146,8 +186,11 @@ function routeFiles(dir) {
146
186
  // The URL segments a route file serves; route groups `(group)` and parallel
147
187
  // slots `@slot` never appear in the URL.
148
188
  function urlSegmentsForRouteFile(file) {
149
- return relative(APP_DIR, file)
150
- .split(sep)
189
+ return urlSegments(relative(APP_DIR, file).split(sep));
190
+ }
191
+
192
+ function urlSegments(pathParts) {
193
+ return pathParts
151
194
  .slice(0, -1)
152
195
  .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
153
196
  }
@@ -259,6 +302,7 @@ const entries = Array.isArray(crons) ? crons : [];
259
302
  const coveredRoutes = new Set();
260
303
  const shippedSchedules = baselineCronSchedules();
261
304
  const preexistingIntervals = [];
305
+ const unscheduledAtBaseline = baselineUnscheduledRoutes();
262
306
 
263
307
  entries.forEach((entry, i) => {
264
308
  const label = `vercel.json crons[${i}]`;
@@ -296,6 +340,14 @@ entries.forEach((entry, i) => {
296
340
  for (const route of cronRoutes) {
297
341
  const url = '/' + route.segments.join('/');
298
342
  if (!coveredRoutes.has(route.file)) {
343
+ const rel = relative(ROOT, route.file).split(sep).join('/');
344
+ if (unscheduledAtBaseline.has(rel)) {
345
+ preexistingIntervals.push(
346
+ `${rel} serves ${url} but no vercel.json cron entry invokes it. The app shipped this route unscheduled, ` +
347
+ `and the update does not add a schedule, so leave it as it is.`,
348
+ );
349
+ continue;
350
+ }
299
351
  problems.push(
300
352
  `${relative(ROOT, route.file)} serves ${url} but no vercel.json cron entry invokes it — it will never run ` +
301
353
  `after publish. Add { "path": "${url}", "schedule": "*/30 * * * *" } to "crons" or remove the route.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [