Keep a mirror in sync: the change feed (start page token, list,
walk-to-the-end), push notifications (changes.watch, files.watch,
channels.stop), and parsing/verifying the webhook Google calls.
Sync loop
- Once:
token ::changes/get-start-page-token()and store it. - Each run (schedule or webhook):
set ::changes/sync-changes(token), applyset.changes(each hasremoved,fileId,file), storeset.new-start-page-token.
Push. watch-changes registers an HTTPS webhook. Notifications
carry only headers (no file data): treat one as "call sync-changes
now". Channels expire (at most 7 days for changes, 1 day for a file)
and are not renewed automatically: renew from a Hot schedule with
renew-watch. Set a channel-token and check it with
verify-notification.
Scopes. drive.readonly for the feed and watches (drive.file
sees only the app's files; drive.metadata.readonly works too).
Values
CHANGE_FIELDS
CHANGE_FIELDS: Str "nextPageToken,newStartPageToken,changes(changeType,time,removed,fileId,driveId,file(id,name,mimeType,size,modifiedTime,createdTime,parents,md5Checksum,sha1Checksum,sha256Checksum,driveId,webViewLink,exportLinks,trashed,version,headRevisionId,shortcutDetails),drive(id,name))"
MAX_CHANGES_CHANNEL_MS
MAX_CHANGES_CHANNEL_MS: Int 604800000
MAX_FILE_CHANNEL_MS
MAX_FILE_CHANNEL_MS: Int 86400000
WATCH_LOCAL_KEYS
WATCH_LOCAL_KEYS: Vec [
"channel-id",
"channel-token",
"expiration",
"expires-in-ms",
"channel-params"
]
Functions
build-channel
fn (address: Str, opts: Map, now-ms: Int): Map
Build a watch channel body (pure): {id, type: "web_hook", address, token?, expiration?, params?}. Options: channel-id (default a new
UUID; at most 64 chars), channel-token (echoed back as
X-Goog-Channel-Token; at most 256 chars; masked in call records),
expiration (epoch ms) or
expires-in-ms (from now-ms), channel-params (e.g. {ttl: "3600"}).
address must be HTTPS with a valid certificate.
change-params
fn (page-token: Str, opts: Map): Map
The query parameters list-changes sends (pure): pageToken, shared-drive flags on, fields = CHANGE_FIELDS, then your Drive parameters (pageSize up to 1000, driveId, includeRemoved, restrictToMyDrive, spaces, includeCorpusRemovals, fields).
changed-files
fn (changes: Any): Vec
The file changes in a ChangeSet (or a Vec of changes) that still exist, as DocFiles, latest change per file.
get-start-page-token
fn (): Str
fn (opts: Map): Str
GET /changes/startPageToken - the token for "changes from now on" (a Str). Pass {driveId} for one shared drive's feed.
header
fn (headers: Map, name: Str): Str?
is-removal
fn (change: Map): Bool
True when a change means the file is gone for this user: removed (deleted or access lost) or trashed.
list-changes
fn (page-token: Str): Map
fn (page-token: Str, opts: Map): Map
GET /changes - one page of changes since page-token: {changes, nextPageToken?}, and on the last page newStartPageToken (save it for next time).
now-ms
fn (): Int
parse-notification
fn (request: Map): Notification
Parse a webhook request into a Notification (pure). Header names are matched case-insensitively. Does not check the channel token: use verify-notification.
removed-file-ids
fn (changes: Any): Vec
The file ids a ChangeSet (or Vec of changes) removed or trashed, de-duplicated. A file that came back later in the same set is not included.
renew-watch
fn (old: Map?, page-token: Str, address: Str): Map
fn (old: Map?, page-token: Str, address: Str, opts: Map): Map
Replace a changes channel before it expires: open a new channel for
page-token first, then stop old (a watch response or {id, resourceId}; null to skip). Notifications may briefly arrive on both.
Returns the new channel. Call it from a schedule shorter than the
channel lifetime.
renew-drive-watch
meta {schedule: "0 3 */6 * *"}
fn (event) {
old ::hot::store/get(state, "channel")
fresh ::google::drive::changes/renew-watch(old, ::hot::store/get(state, "page-token"),
"https://api.example.com/drive/hook", {channel-token: secret, expires-in-ms: 604800000})
::hot::store/put(state, "channel", fresh)
}
stop-channel
fn (channel-id: Str, resource-id: Str): Bool
fn (channel-id: Str, resource-id: Str, opts: Map): Bool
POST /channels/stop - stop notifications for a channel (id and resourceId from the watch response). Only the same user and OAuth client can stop a user's channel. Returns true.
sync-changes
fn (page-token: Str): ChangeSet
fn (page-token: Str, opts: Map): ChangeSet
Read every page of changes since page-token and return a ChangeSet
with the token to use next time. Uses pageSize: 1000 unless set.
::changes ::google::drive::changes
set ::changes/sync-changes(saved-token)
upserts ::changes/changed-files(set) // DocFiles to (re)index
deletes ::changes/removed-file-ids(set) // ids to drop
saved-token set.new-start-page-token
sync-loop
fn (page-token: Str, opts: Map, acc: Vec, pages: Int): ChangeSet
verify-channel-token
fn (request: Map, expected: Str?): Bool
True when the request's X-Goog-Channel-Token equals expected (constant-time compare). Fails closed: an empty expected or a missing header is false. expected is masked in call records.
verify-notification
fn (request: Map): Notification
fn (request: Map, expected: Str): Notification
Verify and parse a Drive webhook: returns the Notification, or an
Err when the channel token is missing or wrong. The 1-arity form
checks against the google.drive.channel.token context variable and
fails (halts) when it is unset, so an unconfigured endpoint never
accepts traffic.
drive-hook
meta {webhook: {service: "drive", path: "/drive/hook"}, secret-headers: ["x-goog-channel-token"]}
fn (request) {
note ::google::drive::changes/verify-notification(request)
cond {
is-err(note) => { ::hot::http/HttpResponse({status: 401, body: "bad channel token"}) }
note.is-sync => { ::hot::http/HttpResponse({status: 200, body: ""}) }
=> {
send("drive:changed", {resource-state: note.resource-state})
::hot::http/HttpResponse({status: 200, body: ""})
}
}
}
google.drive.channel.token
watch-changes
fn (page-token: Str, address: Str): Map
fn (page-token: Str, address: Str, opts: Map): Map
POST /changes/watch - get notified at address when anything after
page-token changes. Returns the channel (id, resourceId,
expiration): keep id and resourceId to stop it. See
build-channel for the channel options; Drive parameters (driveId,
restrictToMyDrive, ...) and credentials also go in opts.
channel ::google::drive::changes/watch-changes(token, "https://api.example.com/drive/hook", {
channel-token: ::hot::ctx/get("google.drive.channel.token"),
expires-in-ms: 518400000 // 6 days; renew before the 7-day cap
})
watch-file
fn (file-id: Str, address: Str): Map
fn (file-id: Str, address: Str, opts: Map): Map
POST /files/{id}/watch - get notified when one file changes (states update, trash, untrash, remove). File channels last at most one day. Options as for watch-changes.
Types
ChangeSet
ChangeSet type {
changes: Vec,
new-start-page-token: Str,
pages: Int
}
Everything that changed since a page token.
changes- Drive change resources in order (changeType,time,removed,fileId,driveId,file,drive)new-start-page-token- store it and pass it to the next syncpages- how many pages were read
Notification
Notification type {
channel-id: Str?,
channel-token: Str?,
channel-expiration: Str?,
resource-id: Str?,
resource-uri: Str?,
resource-state: Str?,
changed: Vec,
message-number: Int?,
is-sync: Bool
}
A Drive push notification, parsed from its headers (the body is empty).
channel-id,channel-token,channel-expirationresource-id(stable),resource-uri(version specific)resource-state-sync(first message on a new channel),change(changes.watch), oradd,remove,update,trash,untrash(files.watch); ignore unknown valueschanged- forupdate:content,properties,parents,children,permissionsmessage-number- increasing per channel (1 forsync)is-sync