@volter/twin-googlemaps 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.
- package/LICENSE +202 -0
- package/README.md +96 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/googlemaps-auth.d.ts +31 -0
- package/dist/src/googlemaps-auth.js +37 -0
- package/dist/src/googlemaps-budget.d.ts +61 -0
- package/dist/src/googlemaps-budget.js +142 -0
- package/dist/src/googlemaps-capabilities.d.ts +3 -0
- package/dist/src/googlemaps-capabilities.js +648 -0
- package/dist/src/googlemaps-conformance.d.ts +8 -0
- package/dist/src/googlemaps-conformance.js +18 -0
- package/dist/src/googlemaps-connector.d.ts +94 -0
- package/dist/src/googlemaps-connector.js +243 -0
- package/dist/src/googlemaps-data.d.ts +87 -0
- package/dist/src/googlemaps-data.js +370 -0
- package/dist/src/googlemaps-perform-harness.d.ts +5 -0
- package/dist/src/googlemaps-perform-harness.js +17 -0
- package/dist/src/googlemaps-server.d.ts +14 -0
- package/dist/src/googlemaps-server.js +25 -0
- package/dist/src/googlemaps-twin.d.ts +2 -0
- package/dist/src/googlemaps-twin.js +1116 -0
- package/dist/src/googlemaps-types.d.ts +45 -0
- package/dist/src/googlemaps-types.js +1 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +65 -0
- package/package.json +52 -0
- package/src/cli.ts +22 -0
- package/src/googlemaps-auth.ts +52 -0
- package/src/googlemaps-budget.ts +168 -0
- package/src/googlemaps-capabilities.ts +766 -0
- package/src/googlemaps-conformance.ts +24 -0
- package/src/googlemaps-connector.ts +250 -0
- package/src/googlemaps-data.ts +399 -0
- package/src/googlemaps-perform-harness.ts +17 -0
- package/src/googlemaps-server.ts +33 -0
- package/src/googlemaps-twin.ts +1110 -0
- package/src/googlemaps-types.ts +47 -0
- package/src/index.ts +124 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { handleGoogleMapsTwinRequest } from "./googlemaps-twin.js";
|
|
2
|
+
export async function checkGoogleMapsConformance(options = {}) {
|
|
3
|
+
const failures = [];
|
|
4
|
+
let checksRun = 0;
|
|
5
|
+
const check = (name, ok) => { checksRun += 1; if (!ok)
|
|
6
|
+
failures.push(name); };
|
|
7
|
+
const geo = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/geocode/json?address=Los%20Angeles', root: options.root });
|
|
8
|
+
check('geocode returns OK with a location', geo.status === 200
|
|
9
|
+
&& geo.body.status === 'OK'
|
|
10
|
+
&& typeof geo.body.results?.[0]?.geometry?.location?.lat === 'number');
|
|
11
|
+
const dm = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/distancematrix/json?origins=34.05,-118.24&destinations=40.71,-74.0', root: options.root });
|
|
12
|
+
check('distancematrix returns OK rows', dm.status === 200
|
|
13
|
+
&& dm.body.status === 'OK'
|
|
14
|
+
&& dm.body.rows?.[0]?.elements?.[0]?.status === 'OK');
|
|
15
|
+
const bad = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/geocode/json', root: options.root });
|
|
16
|
+
check('missing address rejects with INVALID_REQUEST', bad.body.status === 'INVALID_REQUEST');
|
|
17
|
+
return { ok: failures.length === 0, checksRun, failures };
|
|
18
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PerformContext, PushOutcome, RemoteExecute, TwinAction } from '@volter/world-core';
|
|
2
|
+
import { GoogleMapsBudget, type GoogleMapsBudgetOptions } from './googlemaps-budget.js';
|
|
3
|
+
export type GoogleMapsExecute = (req: {
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
}) => Promise<{
|
|
8
|
+
status: number;
|
|
9
|
+
data: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
/** The real Google Maps Platform web-service host. The ONLY place this pack names it as a target. */
|
|
12
|
+
export declare const GOOGLEMAPS_API_BASE = "https://maps.googleapis.com";
|
|
13
|
+
/**
|
|
14
|
+
* THE CHOKE POINT — the one place this pack builds a `GoogleMapsExecute` that really reaches
|
|
15
|
+
* maps.googleapis.com, and therefore the one place the rate budget has to be enforced.
|
|
16
|
+
*
|
|
17
|
+
* Reads the API key from `GOOGLE_MAPS_API_KEY` (never a literal, never a credentials file) and sends
|
|
18
|
+
* it the way Google documents for each surface: as the `key=` query parameter on the legacy
|
|
19
|
+
* `/maps/api/...` web services, and as the `X-Goog-Api-Key` header otherwise (Places API New /
|
|
20
|
+
* Address Validation) — see `googlemaps-auth.ts`, which models the same two forms.
|
|
21
|
+
*
|
|
22
|
+
* EVERY request is guarded: the budget is charged BEFORE it goes out (`checkBudget`, which THROWS
|
|
23
|
+
* instead of returning once the ceiling or a persisted cooldown says stop) and the response is fed
|
|
24
|
+
* back (`recordCall`) so a `Retry-After` / 429 / rate-limit-exhaustion signal becomes a persisted
|
|
25
|
+
* cooldown that makes every later call fail fast WITHOUT touching Google. There is deliberately NO
|
|
26
|
+
* option to disable the guard.
|
|
27
|
+
*
|
|
28
|
+
* No vendor SDK is imported: real-transport `fetch` keeps `@googlemaps/google-maps-services-js` a
|
|
29
|
+
* dev-only dependency, which the architecture guardrail requires.
|
|
30
|
+
*/
|
|
31
|
+
export declare function liveGoogleMapsExecute(opts?: {
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
|
|
36
|
+
budget?: GoogleMapsBudget;
|
|
37
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
38
|
+
budgetOptions?: GoogleMapsBudgetOptions;
|
|
39
|
+
}): GoogleMapsExecute;
|
|
40
|
+
/**
|
|
41
|
+
* Pull geocode results for a set of addresses through an injected client (the
|
|
42
|
+
* real Maps Geocoding API in prod, a fake in tests) and fold each OK result into
|
|
43
|
+
* local state as a `geocode` override. Idempotent on the normalized address.
|
|
44
|
+
*/
|
|
45
|
+
export declare function pullGoogleMapsGeocodes(execute: GoogleMapsExecute, addresses: string[], opts?: {
|
|
46
|
+
root?: string;
|
|
47
|
+
occurredAt?: string;
|
|
48
|
+
}): Promise<number>;
|
|
49
|
+
/**
|
|
50
|
+
* Pull place details for a set of place ids, folding each into local state as a
|
|
51
|
+
* `place` resource snapshot. Idempotent on the place id.
|
|
52
|
+
*/
|
|
53
|
+
export declare function pullGoogleMapsPlaces(execute: GoogleMapsExecute, placeIds: string[], opts?: {
|
|
54
|
+
root?: string;
|
|
55
|
+
occurredAt?: string;
|
|
56
|
+
}): Promise<number>;
|
|
57
|
+
/**
|
|
58
|
+
* D7 consumer-facing pull entry point: pull from the real Maps API (geocodes for the given
|
|
59
|
+
* addresses + details for the given place ids) and fold into the twin in ONE shadow-diffed
|
|
60
|
+
* one observed batch, returning the standard `{ observed, deltasAppended }` result. The inputs ride on the
|
|
61
|
+
* options object (this is an input-driven stateless twin). Idempotent — a re-pull of identical
|
|
62
|
+
* state appends nothing (deltasAppended drops to 0).
|
|
63
|
+
*/
|
|
64
|
+
export declare function syncGoogleMapsFromReal(execute: GoogleMapsExecute, opts?: {
|
|
65
|
+
addresses?: string[];
|
|
66
|
+
placeIds?: string[];
|
|
67
|
+
root?: string;
|
|
68
|
+
occurredAt?: string;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
observed: number;
|
|
71
|
+
deltasAppended: number;
|
|
72
|
+
}>;
|
|
73
|
+
/** A pending `geocode.upsert` action is pushed as a twin-only seed POST. */
|
|
74
|
+
export declare function googleMapsRequestForAction(action: TwinAction): {
|
|
75
|
+
method: string;
|
|
76
|
+
path: string;
|
|
77
|
+
body?: unknown;
|
|
78
|
+
} | null;
|
|
79
|
+
export declare function pushGoogleMapsAction(action: TwinAction, execute: GoogleMapsExecute): Promise<boolean>;
|
|
80
|
+
/** The pack's executor over the kernel's. */
|
|
81
|
+
export declare function googleMapsExecuteOver(execute: RemoteExecute): GoogleMapsExecute;
|
|
82
|
+
/** The refresh adapter: re-read every address the tree already holds a geocode for. The Geocoding API has no
|
|
83
|
+
* inventory to list — what a world knows to ask about IS the addresses it has seen. */
|
|
84
|
+
export declare function syncGoogleMapsFromRemote(execute: RemoteExecute, opts?: {
|
|
85
|
+
root?: string;
|
|
86
|
+
origin?: string;
|
|
87
|
+
occurredAt?: string;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
observed: number;
|
|
90
|
+
deltasAppended: number;
|
|
91
|
+
}>;
|
|
92
|
+
/** The perform adapter. The Google Maps APIs this twin serves are READ-ONLY: they answer questions about
|
|
93
|
+
* the world and accept no writes, so no entry ever crosses — each settles with that reason. */
|
|
94
|
+
export declare function performGoogleMapsAction(_execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome>;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Google Maps CONNECTOR — pull/push against an INJECTED `GoogleMapsExecute`, so every test and
|
|
2
|
+
// every capability verify runs offline against a deterministic fake.
|
|
3
|
+
//
|
|
4
|
+
// `liveGoogleMapsExecute` below is the pack's ONE construction site for a real, network-calling
|
|
5
|
+
// execute — the choke point the rate budget sits inside. Before it existed there was nowhere for a
|
|
6
|
+
// guard to live: the pack only ever received an execute, so a budget could only be something the
|
|
7
|
+
// caller remembered to opt into, and "the caller remembered" is exactly the assumption that cost a
|
|
8
|
+
// ~4.5-day Figma token lockout (2026-07-25). See `googlemaps-budget.ts` for the numbers and the
|
|
9
|
+
// documented limits behind them.
|
|
10
|
+
//
|
|
11
|
+
// The injected contract is UNCHANGED: everything below still takes a plain `GoogleMapsExecute`.
|
|
12
|
+
import { observeResources, projectResources } from '@volter/world-core';
|
|
13
|
+
import { normalizeAddress, stableHash } from "./googlemaps-data.js";
|
|
14
|
+
import { GoogleMapsBudget, GoogleMapsBudgetError, googleMapsCallWeight } from "./googlemaps-budget.js";
|
|
15
|
+
const DEFAULT_OCCURRED_AT = '1970-01-01T00:00:00.000Z';
|
|
16
|
+
/** The real Google Maps Platform web-service host. The ONLY place this pack names it as a target. */
|
|
17
|
+
export const GOOGLEMAPS_API_BASE = 'https://maps.googleapis.com';
|
|
18
|
+
/**
|
|
19
|
+
* THE CHOKE POINT — the one place this pack builds a `GoogleMapsExecute` that really reaches
|
|
20
|
+
* maps.googleapis.com, and therefore the one place the rate budget has to be enforced.
|
|
21
|
+
*
|
|
22
|
+
* Reads the API key from `GOOGLE_MAPS_API_KEY` (never a literal, never a credentials file) and sends
|
|
23
|
+
* it the way Google documents for each surface: as the `key=` query parameter on the legacy
|
|
24
|
+
* `/maps/api/...` web services, and as the `X-Goog-Api-Key` header otherwise (Places API New /
|
|
25
|
+
* Address Validation) — see `googlemaps-auth.ts`, which models the same two forms.
|
|
26
|
+
*
|
|
27
|
+
* EVERY request is guarded: the budget is charged BEFORE it goes out (`checkBudget`, which THROWS
|
|
28
|
+
* instead of returning once the ceiling or a persisted cooldown says stop) and the response is fed
|
|
29
|
+
* back (`recordCall`) so a `Retry-After` / 429 / rate-limit-exhaustion signal becomes a persisted
|
|
30
|
+
* cooldown that makes every later call fail fast WITHOUT touching Google. There is deliberately NO
|
|
31
|
+
* option to disable the guard.
|
|
32
|
+
*
|
|
33
|
+
* No vendor SDK is imported: real-transport `fetch` keeps `@googlemaps/google-maps-services-js` a
|
|
34
|
+
* dev-only dependency, which the architecture guardrail requires.
|
|
35
|
+
*/
|
|
36
|
+
export function liveGoogleMapsExecute(opts = {}) {
|
|
37
|
+
const apiKey = opts.apiKey ?? process.env.GOOGLE_MAPS_API_KEY ?? '';
|
|
38
|
+
if (!apiKey)
|
|
39
|
+
throw new Error('GOOGLE_MAPS_API_KEY is not set — the live Google Maps client needs an API key');
|
|
40
|
+
const baseUrl = opts.baseUrl ?? GOOGLEMAPS_API_BASE;
|
|
41
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
42
|
+
// There is no value a caller can pass to end up with an unguarded execute. `null`/`undefined` (or
|
|
43
|
+
// omitting it) build the default budget; anything that is not a REAL `GoogleMapsBudget` is refused
|
|
44
|
+
// loudly rather than trusted — a duck-typed stand-in with a no-op `checkBudget` would otherwise be
|
|
45
|
+
// the one clean way around the guard.
|
|
46
|
+
if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof GoogleMapsBudget)) {
|
|
47
|
+
throw new Error('liveGoogleMapsExecute: `budget` must be a GoogleMapsBudget — refusing to build a live Google Maps client around an unverified rate guard');
|
|
48
|
+
}
|
|
49
|
+
// The default ledger is keyed by a hash of THIS key — Google's quotas are per project/key, so a
|
|
50
|
+
// cwd-scoped ledger would hand the same key a fresh allowance per checkout/worktree/CI leg.
|
|
51
|
+
const budget = opts.budget instanceof GoogleMapsBudget
|
|
52
|
+
? opts.budget
|
|
53
|
+
: new GoogleMapsBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
54
|
+
return async (req) => {
|
|
55
|
+
requireVendorPath(req.path);
|
|
56
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
57
|
+
const weight = googleMapsCallWeight(method, req.path);
|
|
58
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
59
|
+
const reservation = budget.checkBudget(weight);
|
|
60
|
+
const legacy = req.path.startsWith('/maps/api/');
|
|
61
|
+
const url = new URL(req.path, baseUrl);
|
|
62
|
+
if (legacy)
|
|
63
|
+
url.searchParams.set('key', apiKey);
|
|
64
|
+
const res = await doFetch(url.toString(), {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
Accept: 'application/json',
|
|
68
|
+
...(legacy ? {} : { 'X-Goog-Api-Key': apiKey }),
|
|
69
|
+
...(req.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
70
|
+
},
|
|
71
|
+
...(req.body === undefined ? {} : { body: JSON.stringify(req.body) }),
|
|
72
|
+
});
|
|
73
|
+
const headers = lowerCasedHeaders(res.headers);
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let data = null;
|
|
76
|
+
try {
|
|
77
|
+
data = text ? JSON.parse(text) : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
data = { raw: text };
|
|
81
|
+
}
|
|
82
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
83
|
+
// Retry-After beyond the cap is not something to sleep off) — the cooldown is persisted first
|
|
84
|
+
// either way, so the refusal survives the throw.
|
|
85
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
86
|
+
// call that louder refusal wins; an answer Google Maps ACCEPTED is kept, so a write that landed is
|
|
87
|
+
// never recorded as failed and performed again on retry.
|
|
88
|
+
try {
|
|
89
|
+
budget.recordCall(weight, headers, { status: res.status, reservation });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!(error instanceof GoogleMapsBudgetError) || !res.ok)
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return { status: res.status, data };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Refuse a `path` that is not a same-origin ABSOLUTE PATH.
|
|
100
|
+
*
|
|
101
|
+
* `new URL(path, base)` treats `https://evil.test/x` AND the protocol-relative `//evil.test/x` as
|
|
102
|
+
* absolute and silently retargets the host — and this factory attaches the credential
|
|
103
|
+
* unconditionally, so a caller-supplied absolute path would exfiltrate it to an arbitrary server.
|
|
104
|
+
* Figma's guarded client sidesteps this by concatenating rather than resolving; this execute takes an
|
|
105
|
+
* arbitrary `{ path }` from its caller, so it validates instead. (§9 finding, 2026-07-26.)
|
|
106
|
+
*
|
|
107
|
+
* `/twin/...` is refused for a different reason: those are the twin's OWN seed routes
|
|
108
|
+
* (`*RequestForAction` builds them), which the real vendor has never heard of. Sending one live is a
|
|
109
|
+
* guaranteed 404 that still burns budget and puts the live credential on the wire for nothing.
|
|
110
|
+
*/
|
|
111
|
+
function requireVendorPath(path) {
|
|
112
|
+
if (typeof path !== 'string' || !/^\/(?!\/)/.test(path)) {
|
|
113
|
+
throw new Error(`liveGoogleMapsExecute: path must be a same-origin absolute path beginning with a single "/" (got ${String(path)}) — refusing to let a caller-supplied absolute URL retarget the host with the credential attached`);
|
|
114
|
+
}
|
|
115
|
+
if (path === '/twin' || path.startsWith('/twin/')) {
|
|
116
|
+
throw new Error(`liveGoogleMapsExecute: refusing to send the twin-only path ${path} to real Google Maps — that route exists only in the local twin`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Response headers as a plain lower-cased record — what the kernel's back-off reader expects. */
|
|
120
|
+
function lowerCasedHeaders(h) {
|
|
121
|
+
const out = {};
|
|
122
|
+
h.forEach((v, k) => { out[k.toLowerCase()] = v; });
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Fetch + map geocode results into SyncResource[] (no fold) — shared by pull + syncFromReal. */
|
|
126
|
+
async function collectGoogleMapsGeocodes(execute, addresses) {
|
|
127
|
+
const resources = [];
|
|
128
|
+
for (const address of addresses) {
|
|
129
|
+
const res = await execute({ method: 'GET', path: `/maps/api/geocode/json?address=${encodeURIComponent(address)}` });
|
|
130
|
+
if (res.status < 200 || res.status >= 300)
|
|
131
|
+
continue;
|
|
132
|
+
const data = res.data;
|
|
133
|
+
if (data.status !== 'OK' || !data.results?.length)
|
|
134
|
+
continue;
|
|
135
|
+
const norm = normalizeAddress(address);
|
|
136
|
+
const id = `geocode_${stableHash(norm)}`;
|
|
137
|
+
resources.push({ type: 'geocode', id, fields: { id, normalized: norm, address, result: data.results[0], source: 'connector' } });
|
|
138
|
+
}
|
|
139
|
+
return resources;
|
|
140
|
+
}
|
|
141
|
+
/** Fetch + map place details into SyncResource[] (no fold) — shared by pull + syncFromReal. */
|
|
142
|
+
async function collectGoogleMapsPlaces(execute, placeIds) {
|
|
143
|
+
const resources = [];
|
|
144
|
+
for (const placeId of placeIds) {
|
|
145
|
+
const res = await execute({ method: 'GET', path: `/maps/api/place/details/json?place_id=${encodeURIComponent(placeId)}` });
|
|
146
|
+
if (res.status < 200 || res.status >= 300)
|
|
147
|
+
continue;
|
|
148
|
+
const data = res.data;
|
|
149
|
+
if (data.status !== 'OK' || !data.result)
|
|
150
|
+
continue;
|
|
151
|
+
resources.push({ type: 'place', id: `place_${placeId}`, fields: { id: `place_${placeId}`, placeId, ...data.result, source: 'connector' } });
|
|
152
|
+
}
|
|
153
|
+
return resources;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Pull geocode results for a set of addresses through an injected client (the
|
|
157
|
+
* real Maps Geocoding API in prod, a fake in tests) and fold each OK result into
|
|
158
|
+
* local state as a `geocode` override. Idempotent on the normalized address.
|
|
159
|
+
*/
|
|
160
|
+
export async function pullGoogleMapsGeocodes(execute, addresses, opts = {}) {
|
|
161
|
+
const resources = await collectGoogleMapsGeocodes(execute, addresses);
|
|
162
|
+
fold(resources, opts);
|
|
163
|
+
return resources.length;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Pull place details for a set of place ids, folding each into local state as a
|
|
167
|
+
* `place` resource snapshot. Idempotent on the place id.
|
|
168
|
+
*/
|
|
169
|
+
export async function pullGoogleMapsPlaces(execute, placeIds, opts = {}) {
|
|
170
|
+
const resources = await collectGoogleMapsPlaces(execute, placeIds);
|
|
171
|
+
fold(resources, opts);
|
|
172
|
+
return resources.length;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* D7 consumer-facing pull entry point: pull from the real Maps API (geocodes for the given
|
|
176
|
+
* addresses + details for the given place ids) and fold into the twin in ONE shadow-diffed
|
|
177
|
+
* one observed batch, returning the standard `{ observed, deltasAppended }` result. The inputs ride on the
|
|
178
|
+
* options object (this is an input-driven stateless twin). Idempotent — a re-pull of identical
|
|
179
|
+
* state appends nothing (deltasAppended drops to 0).
|
|
180
|
+
*/
|
|
181
|
+
export async function syncGoogleMapsFromReal(execute, opts = {}) {
|
|
182
|
+
const geocodes = await collectGoogleMapsGeocodes(execute, opts.addresses ?? []);
|
|
183
|
+
const places = await collectGoogleMapsPlaces(execute, opts.placeIds ?? []);
|
|
184
|
+
const resources = [...geocodes, ...places];
|
|
185
|
+
const result = fold(resources, opts);
|
|
186
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
187
|
+
}
|
|
188
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant. */
|
|
189
|
+
function fold(resources, opts) {
|
|
190
|
+
const at = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
191
|
+
return observeResources('googlemaps', resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
|
|
192
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:googlemaps:${at}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/** A pending `geocode.upsert` action is pushed as a twin-only seed POST. */
|
|
196
|
+
export function googleMapsRequestForAction(action) {
|
|
197
|
+
if (action.operation !== 'geocode.upsert')
|
|
198
|
+
return null;
|
|
199
|
+
const fields = action.fields;
|
|
200
|
+
return { method: 'POST', path: '/twin/geocode', body: { address: fields.address, result: fields.result } };
|
|
201
|
+
}
|
|
202
|
+
export async function pushGoogleMapsAction(action, execute) {
|
|
203
|
+
const req = googleMapsRequestForAction(action);
|
|
204
|
+
if (!req)
|
|
205
|
+
return false;
|
|
206
|
+
const result = await execute(req);
|
|
207
|
+
return result.status >= 200 && result.status < 300;
|
|
208
|
+
}
|
|
209
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
210
|
+
/** The pack's executor over the kernel's. */
|
|
211
|
+
export function googleMapsExecuteOver(execute) {
|
|
212
|
+
return async ({ method, path, body }) => {
|
|
213
|
+
const res = await execute({ method, path, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
214
|
+
let data = {};
|
|
215
|
+
if (res.body) {
|
|
216
|
+
try {
|
|
217
|
+
data = JSON.parse(res.body);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
data = { error_message: res.body };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { status: res.status, data };
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** The refresh adapter: re-read every address the tree already holds a geocode for. The Geocoding API has no
|
|
227
|
+
* inventory to list — what a world knows to ask about IS the addresses it has seen. */
|
|
228
|
+
export async function syncGoogleMapsFromRemote(execute, opts = {}) {
|
|
229
|
+
const addresses = [];
|
|
230
|
+
for (const r of projectResources('googlemaps', opts.root)) {
|
|
231
|
+
if (r.type !== 'geocode')
|
|
232
|
+
continue;
|
|
233
|
+
const address = r.address;
|
|
234
|
+
if (typeof address === 'string' && address)
|
|
235
|
+
addresses.push(address);
|
|
236
|
+
}
|
|
237
|
+
return syncGoogleMapsFromReal(googleMapsExecuteOver(execute), { addresses, ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}) });
|
|
238
|
+
}
|
|
239
|
+
/** The perform adapter. The Google Maps APIs this twin serves are READ-ONLY: they answer questions about
|
|
240
|
+
* the world and accept no writes, so no entry ever crosses — each settles with that reason. */
|
|
241
|
+
export async function performGoogleMapsAction(_execute, action, _ctx) {
|
|
242
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type}: the Google Maps APIs answer questions and accept no writes — nothing to perform` } };
|
|
243
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { AddressComponent, GeocodeResult, Geometry, LatLng } from './googlemaps-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic, NON-real geodata. A Google Maps twin cannot reproduce Google's
|
|
4
|
+
* real geospatial index, live traffic, or imagery; instead it returns faithful
|
|
5
|
+
* response SHAPES with stable, deterministic values. A given query always maps
|
|
6
|
+
* to the same canned result so verifies and tests are repeatable offline.
|
|
7
|
+
*/
|
|
8
|
+
/** Stable FNV-ish hash → hex, used to derive deterministic ids/coords. */
|
|
9
|
+
export declare function stableHash(input: unknown): string;
|
|
10
|
+
/** Normalize a free-text address for stable keying (lowercase, collapse ws). */
|
|
11
|
+
export declare function normalizeAddress(address: string): string;
|
|
12
|
+
/** Deterministic lat/lng in a plausible range derived from a seed string. */
|
|
13
|
+
export declare function deterministicLatLng(seed: string): LatLng;
|
|
14
|
+
export declare function viewportAround(loc: LatLng, delta?: number): Geometry['viewport'];
|
|
15
|
+
/** A small named place dataset (canned, not real index lookups). */
|
|
16
|
+
export type CannedPlace = {
|
|
17
|
+
place_id: string;
|
|
18
|
+
formatted_address: string;
|
|
19
|
+
location: LatLng;
|
|
20
|
+
types: string[];
|
|
21
|
+
components: AddressComponent[];
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
/** Returns true for a query the twin deliberately maps to ZERO_RESULTS. */
|
|
25
|
+
export declare function isZeroResultsQuery(q: string): boolean;
|
|
26
|
+
/** Find a canned place by fuzzy name/address substring match. */
|
|
27
|
+
export declare function findPlaceByText(query: string): CannedPlace | null;
|
|
28
|
+
export declare function findPlaceById(placeId: string): CannedPlace | null;
|
|
29
|
+
/** The country short_name (ccTLD-ish) for a canned place, for component filtering. */
|
|
30
|
+
export declare function countryOf(p: CannedPlace): string | undefined;
|
|
31
|
+
/** The locality long_name for a canned place (used in plus-code compound codes). */
|
|
32
|
+
export declare function localityOf(p: CannedPlace): string;
|
|
33
|
+
/** Nearest canned place to a lat/lng (for reverse geocoding). */
|
|
34
|
+
export declare function findPlaceByLatLng(loc: LatLng): CannedPlace;
|
|
35
|
+
export declare function allPlaces(): CannedPlace[];
|
|
36
|
+
/** Localize a formatted_address for a `language` code (no-op for English/unknown). */
|
|
37
|
+
export declare function localizeFormattedAddress(formatted_address: string, language: string | null | undefined): string;
|
|
38
|
+
/** Build a faithful GeocodeResult from a canned place. */
|
|
39
|
+
export declare function geocodeResultFromPlace(p: CannedPlace, locationType?: Geometry['location_type']): GeocodeResult;
|
|
40
|
+
/**
|
|
41
|
+
* Synthesize a deterministic GeocodeResult for an arbitrary (non-canned but
|
|
42
|
+
* valid) address so the twin never falsely 404s a well-formed query. Coordinates
|
|
43
|
+
* are derived from the address hash; shape is faithful.
|
|
44
|
+
*/
|
|
45
|
+
export declare function synthGeocodeResult(address: string): GeocodeResult;
|
|
46
|
+
/** Great-circle distance (haversine) in meters between two points. */
|
|
47
|
+
export declare function haversineMeters(a: LatLng, b: LatLng): number;
|
|
48
|
+
/** Human "5.0 mi" / "1.2 km" text for a distance in meters (imperial default). */
|
|
49
|
+
export declare function distanceText(meters: number, metric?: boolean): string;
|
|
50
|
+
/** Human "15 mins" / "1 hour 5 mins" text for a duration in seconds. */
|
|
51
|
+
export declare function durationText(seconds: number): string;
|
|
52
|
+
/** Deterministic driving duration (seconds) for a distance — ~40 km/h average. */
|
|
53
|
+
export declare function deterministicDuration(meters: number): number;
|
|
54
|
+
/**
|
|
55
|
+
* A tiny polyline encoder (Google's Encoded Polyline Algorithm Format), used to
|
|
56
|
+
* produce a faithful `overview_polyline.points` for Directions responses.
|
|
57
|
+
*/
|
|
58
|
+
export declare function encodePolyline(points: LatLng[]): string;
|
|
59
|
+
/** Deterministic elevation (meters) for a lat/lng. */
|
|
60
|
+
export declare function deterministicElevation(loc: LatLng): number;
|
|
61
|
+
/**
|
|
62
|
+
* Deterministic IANA time zone id + offsets for a lat/lng. When a UTC `timestamp`
|
|
63
|
+
* (seconds) is supplied, northern-hemisphere DST is computed faithfully: zones
|
|
64
|
+
* that observe DST (US/UK) return a non-zero `dstOffset` for timestamps that fall
|
|
65
|
+
* between mid-March and early November; UTC/Japan never observe DST.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deterministicTimeZone(loc: LatLng, timestamp?: number): {
|
|
68
|
+
timeZoneId: string;
|
|
69
|
+
timeZoneName: string;
|
|
70
|
+
rawOffset: number;
|
|
71
|
+
dstOffset: number;
|
|
72
|
+
};
|
|
73
|
+
export declare function encodePlusCode(loc: LatLng, codeLength?: number): string;
|
|
74
|
+
export declare function plusCodeFor(loc: LatLng, localityName: string): {
|
|
75
|
+
global_code: string;
|
|
76
|
+
compound_code: string;
|
|
77
|
+
};
|
|
78
|
+
/** Deterministic next_page_token <-> offset codec for Places pagination. */
|
|
79
|
+
export declare function encodePageToken(query: string, offset: number): string;
|
|
80
|
+
export declare function decodePageToken(token: string): {
|
|
81
|
+
query: string;
|
|
82
|
+
offset: number;
|
|
83
|
+
} | null;
|
|
84
|
+
/** A faithful opening_hours block (weekday_text + periods) for a place. */
|
|
85
|
+
export declare function openingHoursFor(p: CannedPlace): Record<string, unknown>;
|
|
86
|
+
/** A faithful, deterministic set of reviews for a place. */
|
|
87
|
+
export declare function reviewsFor(p: CannedPlace): Record<string, unknown>[];
|